mako_engine/marktrolle.rs
1//! BDEW Rollenmodell — market-participant role configuration.
2//!
3//! The BDEW Rollenmodell für die Marktkommunikation (V2.2, January 2026) explicitly
4//! permits a single legal entity to hold multiple market roles simultaneously.
5//! Common combinations:
6//!
7//! | Combination | Regulatory basis |
8//! |---|---|
9//! | NB + gMSB | §41 MsbG — NB is grundzuständiger MSB for basic meters |
10//! | NB + BKV | Stadtwerke managing their own balance group |
11//! | NB + LF | Vertically integrated utility |
12//! | LF + BKV | Supplier managing its own balance group |
13//!
14//! ## Why role-awareness matters for PID routing
15//!
16//! Several EDIFACT PIDs are **shared across process families** and their correct
17//! inbound destination depends on which role this `makod` instance fills:
18//!
19//! | PID | ORDRSP semantics |
20//! |---|---|
21//! | 19001 (Bestellbestätigung) | → `gpke-konfiguration` when NB receiving from MSB |
22//! | 19001 (Bestellbestätigung) | → `wim-geraeteubernahme` when nMSB receiving from NB |
23//! | 19015 (Bestätigung Gerätewechselabsicht) | → `wim-geraeteubernahme` when NB receiving from nMSB |
24//! | 13003 (MSCONS Summenzeitreihe) | → `mabis-billing` when BKV receiving from BIKO |
25//! | 13003 (MSCONS Summenzeitreihe) | → MaBiS NZR handler when NB receiving from NB |
26//!
27//! By declaring which roles a `makod` instance serves, the engine can register
28//! only the PID routes that apply, preventing both silent dead-letters and
29//! accidental misrouting.
30//!
31//! ## Conflict guard
32//!
33//! [`PidRouter`] panics at build time if two modules register the same PID to
34//! **different** workflow names. Set explicit [`DeploymentRoles`] to exclude
35//! conflicting registrations from modules that don't apply to this instance.
36//!
37//! [`PidRouter`]: crate::pid_router::PidRouter
38
39use std::collections::HashSet;
40
41// ── Marktrolle ────────────────────────────────────────────────────────────────
42
43/// A BDEW market-participant role (Marktrolle).
44///
45/// Declares which roles this `makod` deployment fills within the German energy
46/// market communication (MaKo) ecosystem. A single deployment may hold several
47/// roles simultaneously (see module-level docs).
48///
49/// # Non-exhaustive
50///
51/// New roles may be added as BDEW regulations expand. Match with `_` in
52/// exhaustive arms or use [`DeploymentRoles::contains`] for membership checks.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Marktrolle {
56 /// Netzbetreiber (NB) — distribution/transmission network operator.
57 ///
58 /// Receives the GPKE Anmeldung ANFRAGE (55001 Lieferbeginn / 55002 Lieferende),
59 /// issues ANTWORT messages (55003–55006), runs GPKE Konfiguration (17134/17135
60 /// outbound ORDERS, 19001/19002 inbound ORDRSP). The Kündigung (55016/55017)
61 /// is an LFN↔LFA exchange, not an NB ANFRAGE.
62 Nb,
63
64 /// Lieferant (LF) — energy supplier.
65 ///
66 /// Initiates GPKE Lieferbeginn/Lieferende, receives ANTWORT from NB.
67 /// Registers as inbound-ANTWORT recipient (55003–55006/55018) for the
68 /// LF-side anmeldung workflow.
69 Lf,
70
71 /// grundzuständiger Messstellenbetreiber (gMSB) — incumbent meter operator.
72 ///
73 /// In the WiM MSB-Wechsel (BK6-24-174) receives the Verpflichtungsanfrage/
74 /// Aufforderung (55168, NB→gMSB); also handles WiM Zählerstand/Konfiguration
75 /// (11001–11003, MSCONS/UTILTS). Often the same legal entity as the NB (§41 MsbG).
76 Msb,
77
78 /// nicht-grundzuständiger Messstellenbetreiber (nMSB) — challenger meter operator.
79 ///
80 /// Sends the WiM MSB-Wechsel Anmeldung (55042, MSBN→NB) and Kündigung MSB
81 /// (55039, MSBN→MSBA), plus WiM Geräteübernahme ORDERS (17001, 17009).
82 /// Receives inbound ORDRSP responses 19001/19002 (Bestellbestätigung/Ablehnung)
83 /// and 19015/19016 (Gerätewechselabsicht).
84 Nmsb,
85
86 /// abgebender Messstellenbetreiber (aMSB) — outgoing meter operator.
87 ///
88 /// Receives the Kündigung MSB (55039, from the nMSB) and sends Ende MSB /
89 /// Abmeldung (55051, MSBA→NB). This role is often held by the gMSB after a
90 /// successful nMSB takeover.
91 Amsb,
92
93 /// Bilanzkreisverantwortlicher (BKV) — balance responsible party.
94 ///
95 /// Receives MABIS billing MSCONS (PID 13003 from BIKO: Abrechnungssummenzeitreihe).
96 Bkv,
97
98 /// Übertragungsnetzbetreiber (ÜNB) — transmission system operator.
99 ///
100 /// Issues BG-SZR Kategorie B/C and BK-SZR Kategorie B/C MSCONS (PID 13003).
101 Uenb,
102
103 /// Bilanzkoordinator (BIKO) — balancing coordinator.
104 ///
105 /// Issues Abrechnungssummenzeitreihe MSCONS (PID 13003) to BKV and NB-DZR.
106 Biko,
107
108 /// Energieserviceanbieter (ESA) — energy service provider acting for the
109 /// Anschlussnutzer (PARTIN 37006, "Kommunikationsdaten des ESA Strom").
110 ///
111 /// **Strom only.** An ESA has no Zuordnung to a Marktlokation: its access to
112 /// values rests on the Anschlussnutzer's consent (§49 Abs. 2 Nr. 9 MsbG) and
113 /// a bilateral contract with the MSB, which §34 Abs. 2 S. 2 Nr. 10 MsbG makes
114 /// a mandatory, non-discriminatory Zusatzleistung.
115 ///
116 /// Sends REQOTE Anfrage, ORDERS 17007 (Bestellung/Abbestellung) and
117 /// ORDCHG 39002 (Stornierung); receives QUOTES 15003 and
118 /// ORDRSP 19011/19012/19013/19014, plus the values themselves.
119 ///
120 /// This role is for a deployment that **is** an ESA. An MSB *serving* an ESA
121 /// registers the inbound side under [`Marktrolle::Msb`].
122 Esa,
123
124 /// Gasnetzbetreiber (GNB) — gas network operator (GeLi Gas counterpart of NB).
125 ///
126 /// Receives GeLi Gas Lieferbeginn/Lieferende ANFRAGE messages (44001 ff.)
127 /// and issues the corresponding ANTWORT messages (44003–44006).
128 Gnb,
129
130 /// Lieferant Gas (LFG) — gas supplier (GeLi Gas counterpart of LF).
131 ///
132 /// Initiates GeLi Gas Lieferbeginn/Lieferende (44001/44002) and receives
133 /// the GNB's ANTWORT messages.
134 Lfg,
135
136 /// Lieferant neu (LFN) — the incoming supplier in a Lieferantenwechsel.
137 ///
138 /// Distinct from the generic [`Marktrolle::Lf`] where a process step is
139 /// specific to the *gaining* side of a switch.
140 Lfn,
141
142 /// Lieferant alt (LFA) — the outgoing supplier in a Lieferantenwechsel.
143 ///
144 /// Distinct from the generic [`Marktrolle::Lf`] where a process step is
145 /// specific to the *losing* side of a switch.
146 Lfa,
147
148 /// Marktgebietsverantwortlicher (MGV) — gas market-area manager.
149 ///
150 /// **Gas only.** Operates the Virtueller Handelspunkt and GaBi Gas
151 /// balancing (THE in Germany). Declares its communication data via
152 /// PARTIN 37011 ("Kommunikationsdaten des MGV Gas").
153 Mgv,
154}
155
156impl Marktrolle {
157 /// The canonical upper-case BDEW role code (e.g. `"NB"`, `"ÜNB"`, `"LFG"`).
158 #[must_use]
159 pub const fn as_code(self) -> &'static str {
160 match self {
161 Self::Nb => "NB",
162 Self::Lf => "LF",
163 Self::Msb => "MSB",
164 Self::Nmsb => "NMSB",
165 Self::Amsb => "AMSB",
166 Self::Bkv => "BKV",
167 Self::Uenb => "ÜNB",
168 Self::Biko => "BIKO",
169 Self::Esa => "ESA",
170 Self::Gnb => "GNB",
171 Self::Lfg => "LFG",
172 Self::Lfn => "LFN",
173 Self::Lfa => "LFA",
174 Self::Mgv => "MGV",
175 }
176 }
177
178 /// Parse a canonical upper-case BDEW role code back into a [`Marktrolle`].
179 ///
180 /// Round-trips [`as_code`] exactly (including the umlaut in `"ÜNB"`).
181 /// Returns `None` for anything else — callers decide whether an unknown
182 /// code is an error or simply "not one of ours".
183 ///
184 /// [`as_code`]: Marktrolle::as_code
185 #[must_use]
186 pub fn from_code(code: &str) -> Option<Self> {
187 Some(match code {
188 "NB" => Self::Nb,
189 "LF" => Self::Lf,
190 "MSB" => Self::Msb,
191 "NMSB" => Self::Nmsb,
192 "AMSB" => Self::Amsb,
193 "BKV" => Self::Bkv,
194 "ÜNB" => Self::Uenb,
195 "BIKO" => Self::Biko,
196 "ESA" => Self::Esa,
197 "GNB" => Self::Gnb,
198 "LFG" => Self::Lfg,
199 "LFN" => Self::Lfn,
200 "LFA" => Self::Lfa,
201 "MGV" => Self::Mgv,
202 _ => return None,
203 })
204 }
205
206 /// Map a PARTIN Prüfidentifikator to the sender's [`Marktrolle`].
207 ///
208 /// PARTIN (PIDs 37000–37014) distributes market-participant communication
209 /// data; the PID identifies the sender's role:
210 ///
211 /// | PID | Sender | `Marktrolle` |
212 /// |---|---|---|
213 /// | 37000 | LF Strom | [`Lf`](Self::Lf) |
214 /// | 37001 | NB Strom | [`Nb`](Self::Nb) |
215 /// | 37002 | MSB Strom | [`Msb`](Self::Msb) |
216 /// | 37003 | BKV Strom | [`Bkv`](Self::Bkv) |
217 /// | 37004 | BIKO Strom | [`Biko`](Self::Biko) |
218 /// | 37005 | ÜNB Strom | [`Uenb`](Self::Uenb) |
219 /// | 37006 | ESA Strom | [`Esa`](Self::Esa) |
220 /// | 37008 | LF Gas | [`Lfg`](Self::Lfg) |
221 /// | 37009 | NB Gas | [`Gnb`](Self::Gnb) |
222 /// | 37010 | MSB Gas | [`Msb`](Self::Msb) |
223 /// | 37011 | MGV Gas | [`Mgv`](Self::Mgv) |
224 /// | 37012 | NB Gas (spartenübergreifend) | [`Gnb`](Self::Gnb) |
225 /// | 37013 | MSB Gas (spartenübergreifend) | [`Msb`](Self::Msb) |
226 /// | 37014 | MSB Strom (spartenübergreifend) | [`Msb`](Self::Msb) |
227 ///
228 /// Returns `None` for unrecognised codes (37007 is a gap in the AHB).
229 #[must_use]
230 pub fn from_partin_pid(pid: u32) -> Option<Self> {
231 match pid {
232 37000 => Some(Self::Lf),
233 37001 => Some(Self::Nb),
234 37002 | 37010 | 37013 | 37014 => Some(Self::Msb),
235 37003 => Some(Self::Bkv),
236 37004 => Some(Self::Biko),
237 37005 => Some(Self::Uenb),
238 37006 => Some(Self::Esa),
239 37008 => Some(Self::Lfg),
240 37009 | 37012 => Some(Self::Gnb),
241 37011 => Some(Self::Mgv),
242 _ => None,
243 }
244 }
245}
246
247// Serde representation: the canonical BDEW role code (`"NB"`, `"ÜNB"`, `"LFG"`, …).
248// Used verbatim in persisted partner records and API payloads, so the wire
249// format matches EDIFACT/BO4E role codes exactly.
250impl serde::Serialize for Marktrolle {
251 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
252 serializer.serialize_str(self.as_code())
253 }
254}
255
256impl<'de> serde::Deserialize<'de> for Marktrolle {
257 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
258 let code = String::deserialize(deserializer)?;
259 Self::from_code(&code)
260 .ok_or_else(|| serde::de::Error::custom(format!("unknown Marktrolle code {code:?}")))
261 }
262}
263
264impl std::fmt::Display for Marktrolle {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 f.write_str(self.as_code())
267 }
268}
269
270// ── DeploymentRoles ───────────────────────────────────────────────────────────
271
272/// The set of [`Marktrolle`]s this `makod` deployment fills.
273///
274/// Used by [`EngineModule::register_pids_with_roles`] to conditionally register
275/// PID routes based on which roles are active. Modules check
276/// `roles.contains(Marktrolle::Nb)` before registering role-specific PIDs.
277///
278/// # Constructors
279///
280/// - [`DeploymentRoles::all()`] — registers everything regardless of role
281/// (useful for development and single-role deployments, default).
282/// - [`DeploymentRoles::from_roles`] — explicit set for multi-role conflict resolution.
283/// - Convenience methods: [`nb()`], [`lf()`], [`msb()`], [`nmsb()`] etc.
284///
285/// # Conflict guard
286///
287/// When two modules both register the same PID to **different** workflow names,
288/// `EngineBuilder::build` will detect the conflict and panic. Set exclusive roles
289/// to ensure only one workflow is registered per shared PID:
290///
291/// ```rust,ignore
292/// // NB deployment: GPKE registers 19001/19002 → gpke-konfiguration
293/// // nMSB deployment: WiM registers 19001/19002 → wim-geraeteubernahme
294/// // Combined (conflict!): set roles to prevent double-registration:
295/// use mako_engine::marktrolle::{DeploymentRoles, Marktrolle};
296///
297/// let roles = DeploymentRoles::from_roles([Marktrolle::Nb]);
298/// // Now only GPKE registers 19001/19002; WiM skips its nMSB-conditional block.
299/// ```
300///
301/// [`EngineModule::register_pids_with_roles`]: crate::builder::EngineModule::register_pids_with_roles
302/// [`nb()`]: DeploymentRoles::nb
303/// [`lf()`]: DeploymentRoles::lf
304/// [`msb()`]: DeploymentRoles::msb
305/// [`nmsb()`]: DeploymentRoles::nmsb
306#[derive(Debug, Clone)]
307pub struct DeploymentRoles {
308 /// When `true`, `contains()` returns `true` for every role (matches all).
309 all: bool,
310 roles: HashSet<Marktrolle>,
311}
312
313impl Default for DeploymentRoles {
314 /// Defaults to `all` — every role is considered active.
315 ///
316 /// This preserves backward-compatible behavior (all PIDs registered) for
317 /// deployments that have not yet configured explicit roles. Set explicit
318 /// roles via [`DeploymentRoles::from_roles`] for multi-role conflict safety.
319 fn default() -> Self {
320 Self::all()
321 }
322}
323
324impl DeploymentRoles {
325 /// All roles active — `contains` always returns `true`.
326 ///
327 /// The default for `EngineBuilder`. Modules register all their PIDs
328 /// unconditionally, identical to the pre-role-aware behavior.
329 ///
330 /// **Warning:** if two modules register the same PID to different workflows
331 /// and `all()` is active, the conflict guard in `PidRouter` will panic at
332 /// build time. Use [`from_roles`] to specify exactly which roles apply.
333 ///
334 /// [`from_roles`]: DeploymentRoles::from_roles
335 #[must_use]
336 pub fn all() -> Self {
337 Self {
338 all: true,
339 roles: HashSet::new(),
340 }
341 }
342
343 /// Construct from an explicit set of active roles.
344 ///
345 /// Only modules whose role-conditional PID blocks include at least one of
346 /// these roles will register those PIDs. All non-role-conditional PID blocks
347 /// (i.e., those that don't call `roles.contains(...)`) are always registered.
348 #[must_use]
349 pub fn from_roles(roles: impl IntoIterator<Item = Marktrolle>) -> Self {
350 Self {
351 all: false,
352 roles: roles.into_iter().collect(),
353 }
354 }
355
356 /// Return `true` when `role` is active.
357 ///
358 /// Always returns `true` for [`DeploymentRoles::all()`].
359 #[must_use]
360 pub fn contains(&self, role: Marktrolle) -> bool {
361 self.all || self.roles.contains(&role)
362 }
363
364 /// Return `true` when this is the [`all()`] sentinel (no explicit role list).
365 ///
366 /// [`all()`]: DeploymentRoles::all
367 #[must_use]
368 pub fn is_all(&self) -> bool {
369 self.all
370 }
371
372 // ── Convenience constructors ──────────────────────────────────────────────
373
374 /// NB-only deployment (most common for grid operators).
375 #[must_use]
376 pub fn nb() -> Self {
377 Self::from_roles([Marktrolle::Nb])
378 }
379
380 /// ESA-only deployment (energy service provider side).
381 #[must_use]
382 pub fn esa() -> Self {
383 Self::from_roles([Marktrolle::Esa])
384 }
385
386 /// LF-only deployment (supplier side).
387 #[must_use]
388 pub fn lf() -> Self {
389 Self::from_roles([Marktrolle::Lf])
390 }
391
392 /// gMSB-only deployment (incumbent meter operator).
393 #[must_use]
394 pub fn msb() -> Self {
395 Self::from_roles([Marktrolle::Msb])
396 }
397
398 /// nMSB-only deployment (challenger meter operator).
399 #[must_use]
400 pub fn nmsb() -> Self {
401 Self::from_roles([Marktrolle::Nmsb])
402 }
403
404 /// NB + gMSB (most common municipal utility / Stadtwerke combination).
405 #[must_use]
406 pub fn nb_msb() -> Self {
407 Self::from_roles([Marktrolle::Nb, Marktrolle::Msb])
408 }
409
410 /// NB + BKV (grid operator that also manages its own balance group).
411 #[must_use]
412 pub fn nb_bkv() -> Self {
413 Self::from_roles([Marktrolle::Nb, Marktrolle::Bkv])
414 }
415
416 /// Add a role to an existing set, returning a new `DeploymentRoles`.
417 #[must_use]
418 pub fn with(mut self, role: Marktrolle) -> Self {
419 if !self.all {
420 self.roles.insert(role);
421 }
422 self
423 }
424}
425
426impl FromIterator<Marktrolle> for DeploymentRoles {
427 fn from_iter<T: IntoIterator<Item = Marktrolle>>(iter: T) -> Self {
428 Self::from_roles(iter)
429 }
430}
431
432// ── Command licensing ─────────────────────────────────────────────────────────
433
434/// Why [`resolve_role`] rejected a command submission.
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum LicensingError {
437 /// The command permits several roles and the caller asserted none —
438 /// the engine cannot infer which hat the caller is wearing.
439 MarktrolleRequired,
440 /// The asserted role is not in the command's permitted set.
441 RoleNotPermitted,
442 /// The effective role is not among the deployment's configured roles.
443 RoleNotConfigured,
444}
445
446impl std::fmt::Display for LicensingError {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 match self {
449 Self::MarktrolleRequired => {
450 f.write_str("multi-role command requires an asserted Marktrolle")
451 }
452 Self::RoleNotPermitted => {
453 f.write_str("asserted Marktrolle is not permitted for this command")
454 }
455 Self::RoleNotConfigured => {
456 f.write_str("deployment is not configured for the required Marktrolle")
457 }
458 }
459 }
460}
461
462impl std::error::Error for LicensingError {}
463
464/// Resolve and validate the effective [`Marktrolle`] for a command submission.
465///
466/// Pure licensing policy — no registry lookup, no I/O:
467///
468/// - **Single-role commands** (`permitted.len() == 1`): the role is inferred
469/// from the permitted set; any `asserted` role is deliberately **ignored**
470/// so ERP connectors that always send a fixed role are not rejected.
471/// - **Multi-role commands** (`permitted.len() != 1`): `asserted` must be
472/// `Some` ([`LicensingError::MarktrolleRequired`]) and must be a member of
473/// `permitted` ([`LicensingError::RoleNotPermitted`]).
474///
475/// The effective role is then cross-checked against the deployment
476/// configuration: [`DeploymentRoles::all`] admits every role; an explicit
477/// (possibly empty) role set admits only its members
478/// ([`LicensingError::RoleNotConfigured`]).
479///
480/// # Errors
481///
482/// See [`LicensingError`] for the three rejection reasons.
483pub fn resolve_role(
484 permitted: &[Marktrolle],
485 asserted: Option<Marktrolle>,
486 configured: &DeploymentRoles,
487) -> Result<Marktrolle, LicensingError> {
488 let effective = if permitted.len() == 1 {
489 // Single-role command — fully implied; asserted role is ignored.
490 permitted[0]
491 } else {
492 let r = asserted.ok_or(LicensingError::MarktrolleRequired)?;
493 if !permitted.contains(&r) {
494 return Err(LicensingError::RoleNotPermitted);
495 }
496 r
497 };
498
499 if !configured.contains(effective) {
500 return Err(LicensingError::RoleNotConfigured);
501 }
502
503 Ok(effective)
504}
505
506#[cfg(test)]
507mod licensing_tests {
508 use super::*;
509
510 #[test]
511 fn code_round_trip_for_every_role() {
512 for role in [
513 Marktrolle::Nb,
514 Marktrolle::Lf,
515 Marktrolle::Msb,
516 Marktrolle::Nmsb,
517 Marktrolle::Amsb,
518 Marktrolle::Bkv,
519 Marktrolle::Uenb,
520 Marktrolle::Biko,
521 Marktrolle::Esa,
522 Marktrolle::Gnb,
523 Marktrolle::Lfg,
524 Marktrolle::Lfn,
525 Marktrolle::Lfa,
526 Marktrolle::Mgv,
527 ] {
528 assert_eq!(Marktrolle::from_code(role.as_code()), Some(role));
529 }
530 assert_eq!(Marktrolle::from_code("ÜNB"), Some(Marktrolle::Uenb));
531 assert_eq!(
532 Marktrolle::from_code("nb"),
533 None,
534 "codes are case-sensitive"
535 );
536 assert_eq!(Marktrolle::from_code(""), None);
537 }
538
539 #[test]
540 fn serde_round_trips_as_bdew_code() {
541 for role in [Marktrolle::Nb, Marktrolle::Uenb, Marktrolle::Lfg] {
542 let json = serde_json::to_string(&role).unwrap();
543 assert_eq!(json, format!("\"{}\"", role.as_code()));
544 let back: Marktrolle = serde_json::from_str(&json).unwrap();
545 assert_eq!(back, role);
546 }
547 assert!(serde_json::from_str::<Marktrolle>("\"LfStrom\"").is_err());
548 }
549
550 #[test]
551 fn from_partin_pid_covers_all_partin_pids() {
552 for pid in [
553 37000u32, 37001, 37002, 37003, 37004, 37005, 37006, 37008, 37009, 37010, 37011, 37012,
554 37013, 37014,
555 ] {
556 assert!(
557 Marktrolle::from_partin_pid(pid).is_some(),
558 "from_partin_pid({pid}) should return Some"
559 );
560 }
561 assert_eq!(Marktrolle::from_partin_pid(37000), Some(Marktrolle::Lf));
562 assert_eq!(Marktrolle::from_partin_pid(37008), Some(Marktrolle::Lfg));
563 assert_eq!(Marktrolle::from_partin_pid(37009), Some(Marktrolle::Gnb));
564 assert_eq!(Marktrolle::from_partin_pid(37011), Some(Marktrolle::Mgv));
565 assert_eq!(Marktrolle::from_partin_pid(37014), Some(Marktrolle::Msb));
566 // PID 37007 is not in the AHB (gap)
567 assert_eq!(Marktrolle::from_partin_pid(37007), None);
568 assert_eq!(Marktrolle::from_partin_pid(0), None);
569 }
570
571 #[test]
572 fn single_permitted_infers_and_ignores_assertion() {
573 let configured = DeploymentRoles::lf();
574 // No assertion → inferred.
575 assert_eq!(
576 resolve_role(&[Marktrolle::Lf], None, &configured),
577 Ok(Marktrolle::Lf)
578 );
579 // A wrong assertion is ignored, not rejected.
580 assert_eq!(
581 resolve_role(&[Marktrolle::Lf], Some(Marktrolle::Nb), &configured),
582 Ok(Marktrolle::Lf)
583 );
584 }
585
586 #[test]
587 fn multi_permitted_requires_assertion() {
588 let permitted = [Marktrolle::Nb, Marktrolle::Msb];
589 let configured = DeploymentRoles::nb_msb();
590 assert_eq!(
591 resolve_role(&permitted, None, &configured),
592 Err(LicensingError::MarktrolleRequired)
593 );
594 assert_eq!(
595 resolve_role(&permitted, Some(Marktrolle::Msb), &configured),
596 Ok(Marktrolle::Msb)
597 );
598 }
599
600 #[test]
601 fn multi_permitted_rejects_foreign_assertion() {
602 let permitted = [Marktrolle::Nb, Marktrolle::Msb];
603 let configured = DeploymentRoles::lf();
604 assert_eq!(
605 resolve_role(&permitted, Some(Marktrolle::Lf), &configured),
606 Err(LicensingError::RoleNotPermitted)
607 );
608 }
609
610 #[test]
611 fn configured_cross_check_rejects_unconfigured_role() {
612 // Resolves to LF; only NB is configured.
613 assert_eq!(
614 resolve_role(&[Marktrolle::Lf], None, &DeploymentRoles::nb()),
615 Err(LicensingError::RoleNotConfigured)
616 );
617 // Empty explicit set admits nothing.
618 assert_eq!(
619 resolve_role(&[Marktrolle::Lf], None, &DeploymentRoles::from_roles([])),
620 Err(LicensingError::RoleNotConfigured)
621 );
622 }
623
624 #[test]
625 fn deployment_roles_all_admits_every_role() {
626 assert_eq!(
627 resolve_role(&[Marktrolle::Biko], None, &DeploymentRoles::all()),
628 Ok(Marktrolle::Biko)
629 );
630 assert_eq!(
631 resolve_role(
632 &[Marktrolle::Bkv, Marktrolle::Uenb],
633 Some(Marktrolle::Uenb),
634 &DeploymentRoles::all()
635 ),
636 Ok(Marktrolle::Uenb)
637 );
638 }
639}
640
641#[cfg(test)]
642mod esa_role_tests {
643 use super::*;
644
645 /// An ESA-only deployment activates exactly that role.
646 #[test]
647 fn esa_is_a_selectable_deployment_role() {
648 let roles = DeploymentRoles::esa();
649 assert!(roles.contains(Marktrolle::Esa));
650 assert!(!roles.contains(Marktrolle::Msb));
651 assert!(!roles.is_all());
652 }
653
654 /// An integrated deployment can be both: the MSB serves ESAs and the ESA
655 /// arm consumes values. The two register disjoint PID sets.
656 #[test]
657 fn msb_and_esa_can_be_held_together() {
658 let roles = DeploymentRoles::from_roles([Marktrolle::Msb, Marktrolle::Esa]);
659 assert!(roles.contains(Marktrolle::Msb));
660 assert!(roles.contains(Marktrolle::Esa));
661 }
662}