edifact_mapper/mapper.rs
1//! High-level [`Mapper`] API for EDIFACT-to-BO4E conversion.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5
6use mig_assembly::ConversionService;
7use mig_bo4e::engine::DataBundle;
8use mig_bo4e::MappingEngine;
9
10use crate::data_dir::DataDir;
11use crate::error::MapperError;
12
13/// Result of a BO4E mapping operation.
14pub struct Bo4eResult {
15 /// The PID (Pruefidentifikator) that was detected or specified.
16 pub pid: String,
17 /// The EDIFACT message type (e.g., "UTILMD", "MSCONS").
18 pub message_type: String,
19 /// The message variant (e.g., "UTILMD_Strom", "MSCONS").
20 pub variant: String,
21 /// The mapped BO4E JSON output.
22 pub bo4e: serde_json::Value,
23}
24
25/// High-level facade for bidirectional EDIFACT ↔ BO4E conversion.
26///
27/// Wraps [`DataBundle`] loading with lazy/eager initialization, and provides
28/// convenient accessors for [`ConversionService`] and [`MappingEngine`] instances.
29///
30/// # Inbound (EDIFACT → BO4E)
31///
32/// ```ignore
33/// use edifact_mapper::{DataDir, Mapper};
34///
35/// let mapper = Mapper::from_data_dir(DataDir::auto())?;
36///
37/// // Detect PID from raw EDIFACT (no upfront knowledge needed)
38/// let pid = mapper.detect_pid(edifact_str)?;
39///
40/// // Convert to typed BO4E interchange
41/// let interchange: DynamicInterchange =
42/// mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", &pid)?;
43/// ```
44///
45/// # Outbound (BO4E → EDIFACT)
46///
47/// ```ignore
48/// let edifact = mapper.to_edifact(
49/// &msg_stammdaten, &tx_stammdaten,
50/// "FV2504", "UTILMD_Strom", "55001",
51/// )?;
52/// ```
53///
54/// # Mid-level Access
55///
56/// ```ignore
57/// let cs = mapper.conversion_service("FV2504", "UTILMD_Strom")?;
58/// let engine = mapper.engine("FV2504", "UTILMD_Strom", "55001")?;
59/// ```
60/// A single entry returned by [`Mapper::list_pids`].
61#[derive(Debug, Clone)]
62pub struct PidListEntry {
63 pub fv: String,
64 pub variant: String,
65 pub pid: String,
66 pub beschreibung: String,
67}
68
69pub struct Mapper {
70 data_dir: DataDir,
71 bundles: Mutex<HashMap<String, DataBundle>>,
72}
73
74/// Read one caller-supplied transaction into a [`mig_bo4e::model::MappedTransaktion`].
75///
76/// Accepts both shapes. A `{transaktionsdaten, stammdaten}` object is taken
77/// apart into the two halves; anything else is a bare entity map, which is what
78/// callers passed before the metadata slot existed — including one that already
79/// contains `prozessdaten` among its entities, where the engine's own reverse
80/// merge handles it.
81///
82/// Either key identifies the wrapper — see
83/// [`is_wrapped_transaktion`](mig_bo4e::model::is_wrapped_transaktion). A half
84/// that is absent stands in as empty, so a transaction of metadata alone keeps
85/// its metadata instead of being read as an entity map (issue #153).
86fn split_transaktion(tx: &serde_json::Value) -> mig_bo4e::model::MappedTransaktion {
87 let (transaktionsdaten, stammdaten) = if mig_bo4e::model::is_wrapped_transaktion(tx) {
88 (
89 tx.get("transaktionsdaten")
90 .cloned()
91 .unwrap_or(serde_json::Value::Null),
92 tx.get("stammdaten")
93 .cloned()
94 .unwrap_or_else(|| serde_json::Value::Object(Default::default())),
95 )
96 } else {
97 (serde_json::Value::Null, tx.clone())
98 };
99 mig_bo4e::model::MappedTransaktion {
100 transaktionsdaten,
101 stammdaten,
102 nesting_info: Default::default(),
103 }
104}
105
106impl Mapper {
107 /// Create a new `Mapper` from a [`DataDir`] configuration.
108 ///
109 /// Any format versions marked as [`eager`](DataDir::eager) are loaded immediately.
110 /// All others are loaded lazily on first access.
111 pub fn from_data_dir(data_dir: DataDir) -> Result<Self, MapperError> {
112 let mapper = Self {
113 data_dir,
114 bundles: Mutex::new(HashMap::new()),
115 };
116 let eager_fvs: Vec<String> = mapper.data_dir.eager_fvs().to_vec();
117 for fv in &eager_fvs {
118 mapper.ensure_bundle_loaded(fv)?;
119 }
120 Ok(mapper)
121 }
122
123 /// Ensure that the bundle for `fv` is loaded into memory.
124 fn ensure_bundle_loaded(&self, fv: &str) -> Result<(), MapperError> {
125 let mut bundles = self.bundles.lock().unwrap();
126 if bundles.contains_key(fv) {
127 return Ok(());
128 }
129 let path = self.data_dir.bundle_path(fv);
130 if !path.exists() {
131 return Err(MapperError::BundleNotFound { fv: fv.to_string() });
132 }
133 let bundle = DataBundle::load(&path)?;
134 // `DataBundle::load` has already checked the serialisation format.
135 // That says the file parses, not that its mappings belong with this
136 // crate — the check that would have caught #158.
137 let expected = DataBundle::PRODUCING_VERSION;
138 if !self.data_dir.allows_bundle_from_other_release()
139 && bundle.built_by.as_deref() != Some(expected)
140 {
141 return Err(MapperError::BundleFromOtherRelease {
142 fv: fv.to_string(),
143 built_by: bundle.built_by.clone(),
144 expected: expected.to_string(),
145 path: path.display().to_string(),
146 });
147 }
148 bundles.insert(fv.to_string(), bundle);
149 Ok(())
150 }
151
152 /// Get a [`ConversionService`] for the given format version and variant.
153 ///
154 /// The service can tokenize EDIFACT input and assemble it into a MIG tree.
155 pub fn conversion_service(
156 &self,
157 fv: &str,
158 variant: &str,
159 ) -> Result<ConversionService, MapperError> {
160 self.ensure_bundle_loaded(fv)?;
161 let bundles = self.bundles.lock().unwrap();
162 let bundle = bundles.get(fv).unwrap();
163 let vc = bundle
164 .variant(variant)
165 .ok_or_else(|| MapperError::VariantNotFound {
166 fv: fv.to_string(),
167 variant: variant.to_string(),
168 })?;
169 let mig = vc
170 .mig_schema
171 .as_ref()
172 .ok_or_else(|| MapperError::VariantNotFound {
173 fv: fv.to_string(),
174 variant: format!("{variant} (no MIG schema in bundle)"),
175 })?;
176 Ok(ConversionService::from_mig(mig.clone()))
177 }
178
179 /// Get a [`MappingEngine`] for a specific PID within a format version and variant.
180 ///
181 /// The engine can convert between assembled MIG trees and BO4E JSON.
182 pub fn engine(&self, fv: &str, variant: &str, pid: &str) -> Result<MappingEngine, MapperError> {
183 self.ensure_bundle_loaded(fv)?;
184 let bundles = self.bundles.lock().unwrap();
185 let bundle = bundles.get(fv).unwrap();
186 let vc = bundle
187 .variant(variant)
188 .ok_or_else(|| MapperError::VariantNotFound {
189 fv: fv.to_string(),
190 variant: variant.to_string(),
191 })?;
192 let pid_key = format!("pid_{pid}");
193 let defs = vc
194 .combined_defs
195 .get(&pid_key)
196 .ok_or_else(|| MapperError::PidNotFound {
197 fv: fv.to_string(),
198 variant: variant.to_string(),
199 pid: pid.to_string(),
200 })?;
201 Ok(MappingEngine::from_definitions_with_code_lists(
202 std::sync::Arc::clone(&vc.code_lists),
203 defs.clone(),
204 ))
205 }
206
207 /// Return the [`PidRequirements`] for a specific PID within a format version and variant.
208 ///
209 /// Requirements describe every entity and field the PID expects, including
210 /// AHB status, cardinality, valid code values, and message vs transaction scope.
211 pub fn pid_requirements(
212 &self,
213 fv: &str,
214 variant: &str,
215 pid: &str,
216 ) -> Result<mig_bo4e::pid_requirements::PidRequirements, MapperError> {
217 self.ensure_bundle_loaded(fv)?;
218 let bundles = self.bundles.lock().unwrap();
219 let bundle = bundles.get(fv).unwrap();
220 let vc = bundle
221 .variant(variant)
222 .ok_or_else(|| MapperError::VariantNotFound {
223 fv: fv.to_string(),
224 variant: variant.to_string(),
225 })?;
226 let pid_key = format!("pid_{pid}");
227 vc.pid_requirements
228 .get(&pid_key)
229 .cloned()
230 .ok_or_else(|| MapperError::PidNotFound {
231 fv: fv.to_string(),
232 variant: variant.to_string(),
233 pid: pid.to_string(),
234 })
235 }
236
237 /// Return the PID-agnostic [`Bo4eCatalog`] for a format version.
238 ///
239 /// The catalog contains one entry per BO4E type (BO, COM, Enum) parsed from
240 /// `bo4e-german` source at compile-mappings time. Used by Stammdatenaufbau in
241 /// downstream services.
242 pub fn bo4e_catalog(
243 &self,
244 fv: &str,
245 ) -> Result<mig_bo4e::bo4e_catalog::Bo4eCatalog, MapperError> {
246 self.ensure_bundle_loaded(fv)?;
247 let bundles = self.bundles.lock().unwrap();
248 let bundle = bundles.get(fv).unwrap();
249 Ok(bundle.bo4e_catalog.clone())
250 }
251
252 /// List all PIDs available across all format versions found in the data directory.
253 ///
254 /// Scans for `edifact-data-{FV}.bin` files, loads each bundle, and returns
255 /// one entry per PID per variant. Results are sorted by PID.
256 pub fn list_pids(&self) -> Result<Vec<PidListEntry>, MapperError> {
257 let dir = self.data_dir.data_path();
258 let read_dir = std::fs::read_dir(dir).map_err(|_| MapperError::DataDirNotFound {
259 path: dir.display().to_string(),
260 })?;
261
262 let mut result = Vec::new();
263
264 for entry in read_dir.flatten() {
265 let path = entry.path();
266 if path.extension().is_some_and(|e| e == "bin") {
267 let stem = path
268 .file_stem()
269 .and_then(|s| s.to_str())
270 .unwrap_or("")
271 .to_string();
272 let fv = match stem.strip_prefix("edifact-data-") {
273 Some(v) => v.to_string(),
274 None => continue,
275 };
276 self.ensure_bundle_loaded(&fv)?;
277 let bundles = self.bundles.lock().unwrap();
278 if let Some(bundle) = bundles.get(&fv) {
279 for (variant, vc) in &bundle.variants {
280 for (pid_key, req) in &vc.pid_requirements {
281 let pid = pid_key.strip_prefix("pid_").unwrap_or(pid_key).to_string();
282 result.push(PidListEntry {
283 fv: fv.clone(),
284 variant: variant.clone(),
285 pid,
286 beschreibung: req.beschreibung.clone(),
287 });
288 }
289 }
290 }
291 }
292 }
293
294 result.sort_by(|a, b| a.pid.cmp(&b.pid));
295 Ok(result)
296 }
297
298 /// Validate a BO4E JSON object against PID requirements.
299 ///
300 /// Returns a list of validation errors. Empty list = valid.
301 /// The `json` should be the transaction-level stammdaten (the entity map).
302 pub fn validate_pid(
303 &self,
304 json: &serde_json::Value,
305 fv: &str,
306 variant: &str,
307 pid: &str,
308 ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
309 self.ensure_bundle_loaded(fv)?;
310 let bundles = self.bundles.lock().unwrap();
311 let bundle = bundles.get(fv).unwrap();
312 let vc = bundle
313 .variant(variant)
314 .ok_or_else(|| MapperError::VariantNotFound {
315 fv: fv.to_string(),
316 variant: variant.to_string(),
317 })?;
318 let pid_key = format!("pid_{pid}");
319 let requirements =
320 vc.pid_requirements
321 .get(&pid_key)
322 .ok_or_else(|| MapperError::PidNotFound {
323 fv: fv.to_string(),
324 variant: variant.to_string(),
325 pid: pid.to_string(),
326 })?;
327
328 Ok(mig_bo4e::pid_validation::validate_pid_json(
329 json,
330 requirements,
331 ))
332 }
333
334 /// Validate a typed BO4E struct against PID requirements.
335 ///
336 /// Convenience wrapper that serializes the struct to JSON first.
337 /// Works with any `Pid*Interchange` or `Pid*MessageStammdaten` type.
338 ///
339 /// # Example
340 /// ```ignore
341 /// let interchange = build_55001_interchange();
342 /// let errors = mapper.validate_pid_struct(&interchange, "FV2504", "UTILMD_Strom", "55001")?;
343 /// assert!(errors.is_empty(), "Errors:\n{}", ValidationReport(errors));
344 /// ```
345 pub fn validate_pid_struct(
346 &self,
347 value: &impl serde::Serialize,
348 fv: &str,
349 variant: &str,
350 pid: &str,
351 ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
352 let json = serde_json::to_value(value).map_err(|e| {
353 MapperError::Mapping(mig_bo4e::MappingError::TypeConversion(e.to_string()))
354 })?;
355 self.validate_pid(&json, fv, variant, pid)
356 }
357
358 /// Validate with AHB condition awareness.
359 ///
360 /// Reverse-maps the JSON to EDIFACT segments, evaluates AHB conditions,
361 /// and reports fields as required/optional based on the actual data present.
362 ///
363 /// Falls back to basic validation (without conditions) if no condition
364 /// evaluator is available for the given variant/format version combination.
365 pub fn validate_pid_with_conditions(
366 &self,
367 json: &serde_json::Value,
368 fv: &str,
369 variant: &str,
370 pid: &str,
371 ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
372 self.ensure_bundle_loaded(fv)?;
373 let bundles = self.bundles.lock().unwrap();
374 let bundle = bundles.get(fv).unwrap();
375 let vc = bundle
376 .variant(variant)
377 .ok_or_else(|| MapperError::VariantNotFound {
378 fv: fv.to_string(),
379 variant: variant.to_string(),
380 })?;
381 let pid_key = format!("pid_{pid}");
382
383 let requirements =
384 vc.pid_requirements
385 .get(&pid_key)
386 .ok_or_else(|| MapperError::PidNotFound {
387 fv: fv.to_string(),
388 variant: variant.to_string(),
389 pid: pid.to_string(),
390 })?;
391
392 // Try to get a condition evaluator for this variant
393 let evaluator = crate::evaluator_factory::create_evaluator(variant, fv);
394
395 if let Some(evaluator) = evaluator {
396 // Reverse-map JSON to EDIFACT segments for condition evaluation context
397 let defs = vc
398 .combined_defs
399 .get(&pid_key)
400 .ok_or_else(|| MapperError::PidNotFound {
401 fv: fv.to_string(),
402 variant: variant.to_string(),
403 pid: pid.to_string(),
404 })?;
405 let engine = MappingEngine::from_definitions_with_code_lists(
406 std::sync::Arc::clone(&vc.code_lists),
407 defs.clone(),
408 );
409 let tree = engine.map_all_reverse(json, None);
410
411 // Convert AssembledTree to flat OwnedSegments for EvaluationContext
412 let segments = crate::tree_to_segments::tree_to_owned_segments(&tree);
413
414 // Validate with condition awareness
415 Ok(crate::evaluator_factory::validate_with_boxed_evaluator(
416 evaluator.as_ref(),
417 json,
418 requirements,
419 pid,
420 &segments,
421 ))
422 } else {
423 // No evaluator available — fall back to basic validation
424 Ok(mig_bo4e::pid_validation::validate_pid_json_transaction(
425 json,
426 requirements,
427 ))
428 }
429 }
430
431 /// Convert BO4E JSON back to an EDIFACT string.
432 ///
433 /// Takes message-level stammdaten, a slice of per-transaction stammdaten,
434 /// and produces an EDIFACT message body (UNH through UNT content segments,
435 /// without UNB/UNZ interchange envelope).
436 ///
437 /// # Arguments
438 ///
439 /// * `msg_stammdaten` — message-level entities (e.g., Marktteilnehmer from SG2)
440 /// * `tx_stammdaten` — per-transaction entities (one per transaction/SG4 instance)
441 /// * `fv` — format version (e.g., "FV2504")
442 /// * `variant` — message variant (e.g., "UTILMD_Strom")
443 /// * `pid` — Pruefidentifikator (e.g., "55001")
444 ///
445 /// # Round-tripping output of [`from_edifact`](Self::from_edifact)
446 ///
447 /// `msg_stammdaten` is only half of what the forward direction produced.
448 /// The message header — `nachrichtentyp`, `nachrichtennummer`,
449 /// `erstellungsdatum`, i.e. the wire's `BGM` and `DTM+137` — is in
450 /// `nachrichtendaten`, not in `stammdaten`, so passing `stammdaten` alone
451 /// renders a body without its header and reports nothing (issue #158).
452 /// Use [`to_edifact_nachricht`](Self::to_edifact_nachricht), which takes
453 /// both halves.
454 ///
455 /// # Code fields: names or raw codes
456 ///
457 /// Where the guide gives a code list, [`from_edifact`](Self::from_edifact)
458 /// writes a code as its name (`NAD+Z65` → `"partnerrolle": "kundeDesLf"`);
459 /// the name is the canonical form. This method accepts either: a name is
460 /// written back as its code, and any other value — the raw code `"Z65"`
461 /// included — is written as it is, so both render the same message. No
462 /// code table has a code that is also one of its names, so the two cannot
463 /// be confused. Pinned by `tests/raw_codes_render_like_names.rs`.
464 ///
465 /// # Example
466 ///
467 /// ```ignore
468 /// let edifact = mapper.to_edifact(
469 /// &msg_json,
470 /// &[tx_json],
471 /// "FV2504",
472 /// "UTILMD_Strom",
473 /// "55001",
474 /// )?;
475 /// ```
476 ///
477 /// # Errors
478 ///
479 /// Besides lookup failures, returns [`MapperError::MissingGroupEntrySegment`]
480 /// when the BO4E fills some of a segment group's fields but not the one its
481 /// entry segment is built from — e.g. a `zaehler` with `geraeteNummer` but no
482 /// `zaehlertypMerkmal`, which would render SG10 `CAV` without `CCI`. Such a
483 /// message cannot be parsed back; its group content would be lost.
484 pub fn to_edifact(
485 &self,
486 msg_stammdaten: &serde_json::Value,
487 tx_stammdaten: &[serde_json::Value],
488 fv: &str,
489 variant: &str,
490 pid: &str,
491 ) -> Result<String, MapperError> {
492 self.render_message_body(
493 msg_stammdaten,
494 tx_stammdaten,
495 fv,
496 variant,
497 pid,
498 EntrySegmentCheck::Refuse,
499 )
500 }
501
502 /// Render one message body from a [`Nachricht`] as [`from_edifact`] produced it.
503 ///
504 /// The forward direction splits a message in two: the business objects go to
505 /// `stammdaten`, and the message header — `nachrichtentyp`,
506 /// `nachrichtennummer`, `erstellungsdatum`, which are the `BGM` and
507 /// `DTM+137` of the wire — goes to `nachrichtendaten` beside it.
508 /// [`to_edifact`] takes only the first half, so handing it `stammdaten`
509 /// alone renders a body without its header and says nothing (issue #158).
510 ///
511 /// This takes both, so a caller can give back what it was given:
512 ///
513 /// ```ignore
514 /// let interchange = mapper.from_edifact::<Value, Value>(&edifact, fv, variant, pid)?;
515 /// let body = mapper.to_edifact_nachricht(&interchange.nachrichten[0], fv, variant, pid)?;
516 /// ```
517 ///
518 /// Only the body: the `UNB`/`UNH`/`UNT`/`UNZ` envelope is
519 /// [`to_edifact_interchange`](Self::to_edifact_interchange)'s job.
520 ///
521 /// # Errors
522 ///
523 /// As [`to_edifact`].
524 ///
525 /// [`to_edifact`]: Self::to_edifact
526 /// [`from_edifact`]: Self::from_edifact
527 /// [`Nachricht`]: mig_bo4e::model::Nachricht
528 pub fn to_edifact_nachricht(
529 &self,
530 nachricht: &mig_bo4e::model::Nachricht<serde_json::Value, serde_json::Value>,
531 fv: &str,
532 variant: &str,
533 pid: &str,
534 ) -> Result<String, MapperError> {
535 let mut msg_stammdaten = nachricht.stammdaten.clone();
536 mig_bo4e::model::restore_message_metadata(&mut msg_stammdaten, &nachricht.nachrichtendaten);
537 self.to_edifact(&msg_stammdaten, &nachricht.transaktionen, fv, variant, pid)
538 }
539
540 /// Reverse-map and render one message body. `check` decides what happens to
541 /// a group instance lacking its MIG entry segment: [`to_edifact`] refuses
542 /// it, [`validate_bo4e`] renders it so the validator can report the defect
543 /// as findings instead of failing the whole validation.
544 ///
545 /// [`to_edifact`]: Self::to_edifact
546 /// [`validate_bo4e`]: Self::validate_bo4e
547 fn render_message_body(
548 &self,
549 msg_stammdaten: &serde_json::Value,
550 tx_stammdaten: &[serde_json::Value],
551 fv: &str,
552 variant: &str,
553 pid: &str,
554 check: EntrySegmentCheck,
555 ) -> Result<String, MapperError> {
556 self.ensure_bundle_loaded(fv)?;
557 let bundles = self.bundles.lock().unwrap();
558 let bundle = bundles.get(fv).unwrap();
559 let vc = bundle
560 .variant(variant)
561 .ok_or_else(|| MapperError::VariantNotFound {
562 fv: fv.to_string(),
563 variant: variant.to_string(),
564 })?;
565
566 let tx_group = vc.tx_group(pid).ok_or_else(|| MapperError::PidNotFound {
567 fv: fv.to_string(),
568 variant: variant.to_string(),
569 pid: pid.to_string(),
570 })?;
571
572 let msg_engine = vc.msg_engine(pid);
573 let tx_engine = vc.tx_engine(pid).ok_or_else(|| MapperError::PidNotFound {
574 fv: fv.to_string(),
575 variant: variant.to_string(),
576 pid: pid.to_string(),
577 })?;
578
579 let filtered_mig = vc
580 .filtered_mig(pid)
581 .ok_or_else(|| MapperError::NoMigSchema {
582 fv: fv.to_string(),
583 variant: variant.to_string(),
584 })?;
585
586 // Build MappedMessage from the provided JSON
587 let transaktionen: Vec<mig_bo4e::model::MappedTransaktion> =
588 tx_stammdaten.iter().map(split_transaktion).collect();
589 let mapped = mig_bo4e::model::MappedMessage {
590 nachricht_meta: serde_json::Value::Null,
591 stammdaten: msg_stammdaten.clone(),
592 transaktionen,
593 nesting_info: Default::default(),
594 inter_group_segments: Default::default(),
595 };
596
597 // Reverse map → AssembledTree
598 let tree = MappingEngine::map_interchange_reverse(
599 &msg_engine,
600 &tx_engine,
601 &mapped,
602 tx_group,
603 Some(&filtered_mig),
604 );
605
606 // Disassemble → ordered segments. A group instance whose MIG entry
607 // segment is missing (e.g. SG10 with CAV but no CCI because the BO4E
608 // lacks the field the CCI is built from) renders EDIFACT that no
609 // receiver can assemble, so by default it is refused (#103).
610 let disassembler = mig_assembly::disassembler::Disassembler::new(&filtered_mig);
611 let checked = match check {
612 EntrySegmentCheck::Refuse => disassembler.disassemble_checked(&tree),
613 EntrySegmentCheck::Render => Ok(disassembler.disassemble(&tree)),
614 };
615 let segments = checked.map_err(|e| match e {
616 mig_assembly::AssemblyError::MissingGroupEntrySegment {
617 group_path,
618 source_path,
619 entry_segment,
620 present_segments,
621 } => {
622 let (entities, entry_fields) = describe_entry_segment_mappings(
623 [msg_engine.definitions(), tx_engine.definitions()],
624 &source_path,
625 &entry_segment,
626 );
627 MapperError::MissingGroupEntrySegment(Box::new(
628 crate::error::GroupEntrySegmentError {
629 pid: pid.to_string(),
630 group_path,
631 source_path,
632 entry_segment,
633 present_segments,
634 entities,
635 entry_fields,
636 },
637 ))
638 }
639 other => MapperError::Assembly(other),
640 })?;
641
642 // Render to EDIFACT string with default delimiters
643 let delimiters = edifact_primitives::EdifactDelimiters::default();
644 Ok(mig_assembly::renderer::render_edifact(
645 &segments,
646 &delimiters,
647 ))
648 }
649
650 /// Convert a typed BO4E struct to an EDIFACT string.
651 ///
652 /// Convenience wrapper that serializes the struct to JSON first.
653 /// The struct should serialize to the `Nachricht` shape:
654 /// `{ "stammdaten": {...}, "transaktionen": [{...}] }`
655 pub fn to_edifact_struct(
656 &self,
657 nachricht: &impl serde::Serialize,
658 fv: &str,
659 variant: &str,
660 pid: &str,
661 ) -> Result<String, MapperError> {
662 let json = serde_json::to_value(nachricht)
663 .map_err(|e| MapperError::Serialization(e.to_string()))?;
664
665 let msg_stammdaten = json
666 .get("stammdaten")
667 .cloned()
668 .unwrap_or(serde_json::Value::Object(Default::default()));
669
670 let tx_stammdaten: Vec<serde_json::Value> = json
671 .get("transaktionen")
672 .and_then(|v| v.as_array())
673 .cloned()
674 .unwrap_or_default();
675
676 self.to_edifact(&msg_stammdaten, &tx_stammdaten, fv, variant, pid)
677 }
678
679 /// Parse an EDIFACT interchange string into a typed PID interchange struct.
680 ///
681 /// Runs the full pipeline: tokenize → split messages → assemble → forward-map → deserialize.
682 /// The type parameters `M` and `T` are the message-level and transaction-level
683 /// stammdaten types from the generated PID module.
684 ///
685 /// # Example
686 ///
687 /// ```ignore
688 /// use bo4e_edifact_types::generated::fv2504::utilmd::pids::pid_55001::*;
689 ///
690 /// let interchange: Interchange<Pid55001MsgStammdaten, Pid55001TxStammdaten> =
691 /// mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", "55001")?;
692 ///
693 /// let tx = &interchange.nachrichten[0].transaktionen[0];
694 /// println!("Vorgang: {}", tx.prozessdaten.vorgang_id);
695 /// ```
696 ///
697 /// Mapping is lossy for content the assembler cannot place: segments the
698 /// PID's AHB does not cover, and segments whose group lacks its entry segment
699 /// (e.g. SG10 `CAV` without `CCI`). They have no BO4E representation and are
700 /// dropped. The conversion still succeeds, so that everything else in the
701 /// message is available; each dropped segment is logged as a `tracing`
702 /// warning. Use [`from_edifact_with_diagnostics`] to inspect them in code
703 /// (e.g. to reject such messages).
704 ///
705 /// [`from_edifact_with_diagnostics`]: Self::from_edifact_with_diagnostics
706 pub fn from_edifact<M, T>(
707 &self,
708 edifact: &str,
709 fv: &str,
710 variant: &str,
711 pid: &str,
712 ) -> Result<mig_bo4e::model::Interchange<M, T>, MapperError>
713 where
714 M: serde::de::DeserializeOwned,
715 T: serde::de::DeserializeOwned,
716 {
717 let (interchange, diagnostics) =
718 self.from_edifact_with_diagnostics(edifact, fv, variant, pid)?;
719 // This signature has no room for diagnostics, and dropped content must
720 // not go unnoticed (#103): log it for callers that don't ask for it.
721 for d in &diagnostics {
722 tracing::warn!(
723 fv,
724 variant,
725 pid,
726 kind = ?d.kind,
727 segment = %d.segment_id,
728 position = d.position,
729 "from_edifact: {}",
730 d.message
731 );
732 }
733 Ok(interchange)
734 }
735
736 /// [`from_edifact`], plus the structure diagnostics raised while assembling.
737 ///
738 /// A non-empty diagnostic list does not mean the conversion failed — it means
739 /// the BO4E result does not represent everything the EDIFACT carried. In
740 /// particular [`SkippedUnknownSegment`] marks a segment outside the PID's AHB
741 /// that the assembler advanced past, and [`OrphanedGroupSegment`] a segment
742 /// the MIG defines but whose group's entry segment is missing; in both cases
743 /// its content is absent from the result.
744 ///
745 /// [`from_edifact`]: Self::from_edifact
746 /// [`SkippedUnknownSegment`]: mig_assembly::StructureDiagnosticKind::SkippedUnknownSegment
747 /// [`OrphanedGroupSegment`]: mig_assembly::StructureDiagnosticKind::OrphanedGroupSegment
748 pub fn from_edifact_with_diagnostics<M, T>(
749 &self,
750 edifact: &str,
751 fv: &str,
752 variant: &str,
753 pid: &str,
754 ) -> Result<
755 (
756 mig_bo4e::model::Interchange<M, T>,
757 Vec<mig_assembly::StructureDiagnostic>,
758 ),
759 MapperError,
760 >
761 where
762 M: serde::de::DeserializeOwned,
763 T: serde::de::DeserializeOwned,
764 {
765 self.ensure_bundle_loaded(fv)?;
766 let bundles = self.bundles.lock().unwrap();
767 let bundle = bundles.get(fv).unwrap();
768 let vc = bundle
769 .variant(variant)
770 .ok_or_else(|| MapperError::VariantNotFound {
771 fv: fv.to_string(),
772 variant: variant.to_string(),
773 })?;
774
775 let tx_group = vc.tx_group(pid).ok_or_else(|| MapperError::PidNotFound {
776 fv: fv.to_string(),
777 variant: variant.to_string(),
778 pid: pid.to_string(),
779 })?;
780
781 let msg_engine = vc.msg_engine(pid);
782 let tx_engine = vc.tx_engine(pid).ok_or_else(|| MapperError::PidNotFound {
783 fv: fv.to_string(),
784 variant: variant.to_string(),
785 pid: pid.to_string(),
786 })?;
787
788 let filtered_mig = vc
789 .filtered_mig(pid)
790 .ok_or_else(|| MapperError::NoMigSchema {
791 fv: fv.to_string(),
792 variant: variant.to_string(),
793 })?;
794
795 // Tokenize → split → assemble. Same assembler config as the v2 `convert`
796 // route: `strict_code_matching` disambiguates merged sibling slots, and
797 // `skip_unknown_segments` keeps the cursor moving past AHB-foreign
798 // segments — without it the cursor stalls on the first one and the whole
799 // message tail is silently dropped from the BO4E result.
800 let svc = ConversionService::from_mig(filtered_mig);
801 let (chunks, trees, assembly_diagnostics) = svc
802 .convert_interchange_to_trees_with_diagnostics(
803 edifact,
804 mig_assembly::assembler::AssemblerConfig {
805 strict_code_matching: true,
806 skip_unknown_segments: true,
807 ..Default::default()
808 },
809 )?;
810
811 let tree = trees.first().ok_or_else(|| {
812 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
813 "No messages in interchange".to_string(),
814 ))
815 })?;
816
817 // Extract envelope metadata
818 let interchangedaten = mig_bo4e::model::extract_interchangedaten(&chunks.envelope);
819 let msg_chunk = chunks.messages.first().ok_or_else(|| {
820 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
821 "No message chunks".to_string(),
822 ))
823 })?;
824 let (unh_ref, nachrichten_typ) = mig_bo4e::model::extract_unh_fields(&msg_chunk.unh);
825 let nachrichtendaten = mig_bo4e::model::Nachrichtendaten {
826 unh_referenz: unh_ref,
827 nachrichten_typ,
828 nachricht: Default::default(),
829 };
830
831 // Forward-map to typed interchange
832 let interchange = MappingEngine::map_interchange_typed::<M, T>(
833 &msg_engine,
834 &tx_engine,
835 tree,
836 tx_group,
837 true,
838 nachrichtendaten,
839 interchangedaten,
840 )
841 .map_err(|e| MapperError::Serialization(e.to_string()))?;
842
843 Ok((interchange, assembly_diagnostics))
844 }
845
846 /// Detect the PID (Pruefidentifikator) from a raw EDIFACT interchange.
847 ///
848 /// Tokenizes the input, splits into messages, and extracts the PID from the
849 /// first message using the RFF+Z13 segment (primary) or BGM+STS fallback.
850 ///
851 /// This enables inbound message processing where the PID is not known upfront:
852 ///
853 /// ```ignore
854 /// let pid = mapper.detect_pid(edifact_str)?;
855 /// let interchange: MyType = mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", &pid)?;
856 /// ```
857 pub fn detect_pid(&self, edifact: &str) -> Result<String, MapperError> {
858 let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
859 let chunks = mig_assembly::split_messages(segments)?;
860 let msg_chunk = chunks.messages.first().ok_or_else(|| {
861 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
862 "No messages found in EDIFACT content".to_string(),
863 ))
864 })?;
865 let msg_segments = msg_chunk.message_segments();
866 mig_assembly::pid_detect::detect_pid(&msg_segments).map_err(MapperError::Assembly)
867 }
868
869 /// Validate raw EDIFACT against its AHB rules.
870 ///
871 /// This is the same pipeline as the v2 API's `POST /api/v2/validate`
872 /// (`run_validation`) — both call [`validate_edifact_message`] — exposed here
873 /// as a library call so consumers (e.g. mako.hive) get full raw-EDIFACT
874 /// validation without running the API server. Detects the PID, resolves the
875 /// owning variant + its pre-built [`AhbWorkflow`] from the loaded bundle,
876 /// assembles the message, and runs the shared validation core.
877 ///
878 /// Requires the bundle for `fv` to carry `pid_ahb_workflows` (baked in at
879 /// compile-mappings). Returns [`MapperError::PidNotFound`] if no loaded variant
880 /// has a workflow for the detected PID.
881 ///
882 /// [`validate_edifact_message`]: automapper_validation::validate_edifact_message
883 /// [`AhbWorkflow`]: automapper_validation::AhbWorkflow
884 pub fn validate_edifact(
885 &self,
886 edifact: &str,
887 fv: &str,
888 level: automapper_validation::ValidationLevel,
889 ) -> Result<automapper_validation::ValidationReport, MapperError> {
890 self.validate_edifact_inner(edifact, fv, None, level)
891 }
892
893 /// [`validate_edifact`], but validating against a PID the caller already knows.
894 ///
895 /// Use this when the PID comes from somewhere other than the message — a form,
896 /// a route, a job definition. It skips PID detection, which only works for
897 /// message types that carry the Prüfidentifikator in `RFF+Z13` (UTILMD); for
898 /// ORDERS, MSCONS, IFTSTA and the rest, detection cannot recover a PID that the
899 /// caller already has.
900 ///
901 /// [`validate_edifact`]: Self::validate_edifact
902 pub fn validate_edifact_for_pid(
903 &self,
904 edifact: &str,
905 fv: &str,
906 variant: &str,
907 pid: &str,
908 level: automapper_validation::ValidationLevel,
909 ) -> Result<automapper_validation::ValidationReport, MapperError> {
910 self.validate_edifact_inner(edifact, fv, Some((variant, pid)), level)
911 }
912
913 fn validate_edifact_inner(
914 &self,
915 edifact: &str,
916 fv: &str,
917 known: Option<(&str, &str)>,
918 level: automapper_validation::ValidationLevel,
919 ) -> Result<automapper_validation::ValidationReport, MapperError> {
920 self.ensure_bundle_loaded(fv)?;
921 let bundles = self.bundles.lock().unwrap();
922 let bundle = bundles.get(fv).unwrap();
923
924 // Tokenize → split → first message (same as `detect_pid`).
925 let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
926 let chunks = mig_assembly::split_messages(segments)?;
927 let msg_chunk = chunks.messages.first().ok_or_else(|| {
928 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
929 "No messages found in EDIFACT content".to_string(),
930 ))
931 })?;
932
933 // Resolve the PID: detect it when the caller doesn't know it, and resolve
934 // the owning variant from the bundle. When the caller does know both (the
935 // `validate_bo4e` path), take them as given — detection only works for
936 // message types that carry the PID in RFF+Z13 (UTILMD), so re-deriving a
937 // PID the caller already supplied would fail on ORDERS, MSCONS, IFTSTA, …
938 let (pid, variant, vc) = match known {
939 Some((variant, pid)) => {
940 let vc = bundle
941 .variant(variant)
942 .ok_or_else(|| MapperError::VariantNotFound {
943 fv: fv.to_string(),
944 variant: variant.to_string(),
945 })?;
946 (pid.to_string(), variant.to_string(), vc)
947 }
948 None => {
949 let pid = mig_assembly::pid_detect::detect_pid(&msg_chunk.message_segments())
950 .map_err(MapperError::Assembly)?;
951 let pid_key = format!("pid_{pid}");
952 let (variant, vc) = bundle
953 .variants
954 .iter()
955 .find(|(_, vc)| vc.pid_ahb_workflows.contains_key(&pid_key))
956 .ok_or_else(|| MapperError::PidNotFound {
957 fv: fv.to_string(),
958 variant: "?".to_string(),
959 pid: pid.clone(),
960 })?;
961 (pid, variant.clone(), vc)
962 }
963 };
964 let pid_key = format!("pid_{pid}");
965
966 let workflow =
967 vc.pid_ahb_workflows
968 .get(&pid_key)
969 .ok_or_else(|| MapperError::PidNotFound {
970 fv: fv.to_string(),
971 variant: variant.clone(),
972 pid: pid.clone(),
973 })?;
974 let filtered_mig = vc
975 .filtered_mig(&pid)
976 .ok_or_else(|| MapperError::NoMigSchema {
977 fv: fv.to_string(),
978 variant: variant.clone(),
979 })?;
980
981 // Segments the validator sees: this message's body for the filtered MIG,
982 // plus the interchange UNZ when the MIG covers it (e.g. MSCONS).
983 let mut all_segments = msg_chunk.segments_for_mig(&filtered_mig);
984 if filtered_mig.segments.iter().any(|s| s.id == "UNZ") {
985 if let Some(unz) = &chunks.unz {
986 all_segments.push(unz.clone());
987 }
988 }
989
990 // Same evaluator resolution + fallback the v2 route uses. The explicit
991 // target type lets each arm coerce (Box<dyn> → Arc<dyn>; Arc<Concrete> →
992 // Arc<dyn> unsize) — a `.map(Arc::from)` chain can't infer that.
993 let evaluator: std::sync::Arc<dyn automapper_validation::ConditionEvaluator> =
994 match crate::evaluator_factory::create_evaluator(&variant, fv) {
995 Some(boxed) => std::sync::Arc::from(boxed),
996 None => std::sync::Arc::new(
997 automapper_validation::UtilmdStromConditionEvaluatorFV2504::default(),
998 ),
999 };
1000 let external = automapper_validation::eval::NoOpExternalProvider;
1001
1002 let mut report = automapper_validation::validate_edifact_message(
1003 &all_segments,
1004 &filtered_mig,
1005 workflow,
1006 evaluator,
1007 &external,
1008 level,
1009 );
1010
1011 // Enrich findings with BO4E field paths so consumers can map the
1012 // segment-path findings back to the BO4E form (same enrichment the v2
1013 // `validate-bo4e` route applies). Sourced entirely from the bundle: the
1014 // combined mapping defs, the PID-filtered MIG, and a reverse resolver
1015 // built from the full MIG — no generated schema files needed.
1016 if let (Some(mig), Some(defs)) = (vc.mig_schema.as_ref(), vc.combined_defs.get(&pid_key)) {
1017 let reverse = mig_bo4e::path_resolver::ReversePathResolver::from_mig(mig);
1018 let field_index =
1019 mig_bo4e::Bo4eFieldIndex::build_with_resolver(defs, &filtered_mig, &reverse);
1020 report.enrich_bo4e_paths(|path, hint| field_index.resolve(path, hint));
1021 }
1022
1023 Ok(report)
1024 }
1025
1026 /// Validate BO4E JSON against the AHB rules of its Prüfidentifikator.
1027 ///
1028 /// This is [`validate_edifact`] with a reverse-mapping front end: the BO4E
1029 /// input is rendered to a complete EDIFACT interchange
1030 /// ([`to_edifact_interchange`]) and that interchange is validated. Because it
1031 /// is literally the same call, the findings are the ones the EDIFACT
1032 /// validation reports for the message this BO4E describes — including the
1033 /// `bo4e_path` enrichment that points each finding back at the BO4E field it
1034 /// came from. Callers working in BO4E (forms, assistants) therefore do not
1035 /// need their own EDIFACT-path-to-BO4E-path translation.
1036 ///
1037 /// `envelope` fills UNB/UNZ. Pass `None` unless the message type's MIG covers
1038 /// the interchange envelope (e.g. MSCONS) — for the others the envelope is
1039 /// outside the AHB and a neutral placeholder is used.
1040 ///
1041 /// Two classes of finding cannot appear here, because the BO4E input has no
1042 /// counterpart for them: the UNT segment-count check (the trailer is
1043 /// regenerated) and skipped-unknown-segment diagnostics (segments outside the
1044 /// AHB have no BO4E representation).
1045 ///
1046 /// [`validate_edifact`]: Self::validate_edifact
1047 /// [`to_edifact_interchange`]: Self::to_edifact_interchange
1048 pub fn validate_bo4e(
1049 &self,
1050 msg_stammdaten: &serde_json::Value,
1051 tx_stammdaten: &[serde_json::Value],
1052 fv: &str,
1053 variant: &str,
1054 pid: &str,
1055 envelope: Option<&InterchangeEnvelope>,
1056 level: automapper_validation::ValidationLevel,
1057 ) -> Result<automapper_validation::ValidationReport, MapperError> {
1058 let placeholder;
1059 let envelope = match envelope {
1060 Some(e) => e,
1061 None => {
1062 placeholder = InterchangeEnvelope {
1063 sender: EdifactParty::bdew("9900000000001"),
1064 receiver: EdifactParty::bdew("9900000000002"),
1065 interchange_ref: "1".to_string(),
1066 };
1067 &placeholder
1068 }
1069 };
1070
1071 // Rendered without the entry-segment check `to_edifact_interchange`
1072 // applies: a group missing its entry segment is exactly the kind of
1073 // defect validation exists to report (as missing-field and structure
1074 // findings), so it must not abort the validation.
1075 let edifact = self.render_interchange(
1076 envelope,
1077 &[InterchangeMessage {
1078 message_ref: "1".to_string(),
1079 msg_stammdaten: msg_stammdaten.clone(),
1080 tx_stammdaten: tx_stammdaten.to_vec(),
1081 fv: fv.to_string(),
1082 variant: variant.to_string(),
1083 pid: pid.to_string(),
1084 }],
1085 EntrySegmentCheck::Render,
1086 &EnvelopeOptions::default(),
1087 )?;
1088
1089 // The PID is given, not detected: for every message type but UTILMD the
1090 // rendered EDIFACT carries no RFF+Z13 to detect it from.
1091 self.validate_edifact_for_pid(&edifact, fv, variant, pid, level)
1092 }
1093
1094 /// Get the UNH association code for a variant (e.g., `"S2.1"`, `"2.4c"`).
1095 ///
1096 /// This is the version string from the MIG schema, used as the last component
1097 /// of the UNH S009 composite: `UTILMD:D:11A:UN:S2.1`.
1098 ///
1099 /// # Example
1100 /// ```ignore
1101 /// let code = mapper.association_code("FV2604", "UTILMD_Strom")?;
1102 /// assert_eq!(code, "S2.1");
1103 /// ```
1104 pub fn association_code(&self, fv: &str, variant: &str) -> Result<String, MapperError> {
1105 let meta = self.message_metadata(fv, variant)?;
1106 Ok(meta.association_code)
1107 }
1108
1109 /// Get full message metadata for a variant, including the UNH S009 components.
1110 ///
1111 /// Returns the message type, UN/EDIFACT release code, and association code
1112 /// needed to construct UNH segments.
1113 pub fn message_metadata(
1114 &self,
1115 fv: &str,
1116 variant: &str,
1117 ) -> Result<MessageMetadata, MapperError> {
1118 self.ensure_bundle_loaded(fv)?;
1119 let bundles = self.bundles.lock().unwrap();
1120 let bundle = bundles.get(fv).unwrap();
1121 let vc = bundle
1122 .variant(variant)
1123 .ok_or_else(|| MapperError::VariantNotFound {
1124 fv: fv.to_string(),
1125 variant: variant.to_string(),
1126 })?;
1127 let mig = vc
1128 .mig_schema
1129 .as_ref()
1130 .ok_or_else(|| MapperError::NoMigSchema {
1131 fv: fv.to_string(),
1132 variant: variant.to_string(),
1133 })?;
1134 Ok(MessageMetadata {
1135 message_type: mig.message_type.clone(),
1136 release: release_code_for_message_type(&mig.message_type),
1137 association_code: mig.version.clone(),
1138 })
1139 }
1140
1141 /// Convert BO4E JSON to a complete EDIFACT interchange with envelope segments.
1142 ///
1143 /// Produces a full interchange including UNA, UNB, UNH, message body, UNT, and UNZ.
1144 ///
1145 /// # The envelope is regenerated, not reproduced
1146 ///
1147 /// This always emits a `UNA` service string advice and stamps the `UNB`
1148 /// date and time from the clock, so a render is never byte-identical to the
1149 /// interchange it came from: an input carrying no `UNA` gains one, and its
1150 /// interchange date becomes today (issue #161). That is right for a
1151 /// re-send, and wrong for a caller checking that a conversion did not
1152 /// change the message.
1153 ///
1154 /// Two ways to check that instead:
1155 ///
1156 /// - compare message **bodies**, which
1157 /// [`to_edifact_nachricht`](Self::to_edifact_nachricht) renders without
1158 /// any envelope;
1159 /// - or reproduce the envelope with
1160 /// [`to_edifact_interchange_with`](Self::to_edifact_interchange_with) and
1161 /// [`EnvelopeOptions`], which take the `UNA` decision and the `UNB` date
1162 /// and time from the caller.
1163 ///
1164 /// Neither reproduces non-default delimiters: the whole render uses
1165 /// [`EdifactDelimiters::default`](edifact_primitives::EdifactDelimiters::default).
1166 ///
1167 /// # Example
1168 /// ```ignore
1169 /// let edifact = mapper.to_edifact_interchange(
1170 /// &InterchangeEnvelope {
1171 /// sender: EdifactParty::bdew("9900000000003"),
1172 /// receiver: EdifactParty::bdew("9900000000001"),
1173 /// interchange_ref: "REF001".to_string(),
1174 /// },
1175 /// &[InterchangeMessage {
1176 /// message_ref: "MSG001".to_string(),
1177 /// msg_stammdaten: serde_json::json!({"marktteilnehmer": []}),
1178 /// tx_stammdaten: vec![serde_json::json!({"prozessdaten": {"pruefidentifikator": "55001"}})],
1179 /// fv: "FV2604".to_string(),
1180 /// variant: "UTILMD_Strom".to_string(),
1181 /// pid: "55001".to_string(),
1182 /// }],
1183 /// )?;
1184 /// assert!(edifact.starts_with("UNA:+.? '"));
1185 /// ```
1186 ///
1187 /// # Errors
1188 ///
1189 /// Fails like [`to_edifact`](Self::to_edifact), including
1190 /// [`MapperError::MissingGroupEntrySegment`] for a group that would be
1191 /// rendered without its entry segment.
1192 pub fn to_edifact_interchange(
1193 &self,
1194 envelope: &InterchangeEnvelope,
1195 messages: &[InterchangeMessage],
1196 ) -> Result<String, MapperError> {
1197 self.render_interchange(
1198 envelope,
1199 messages,
1200 EntrySegmentCheck::Refuse,
1201 &EnvelopeOptions::default(),
1202 )
1203 }
1204
1205 /// Like [`to_edifact_interchange`](Self::to_edifact_interchange), with
1206 /// control over how the envelope is built.
1207 ///
1208 /// The default regenerates it — a fresh `UNA` and a `UNB` timestamped from
1209 /// the clock — which is right for a re-send but means a render can never
1210 /// equal its input. [`EnvelopeOptions`] lets a caller that has the original
1211 /// ask for it back instead (issue #161).
1212 ///
1213 /// # Errors
1214 ///
1215 /// As [`to_edifact_interchange`](Self::to_edifact_interchange).
1216 pub fn to_edifact_interchange_with(
1217 &self,
1218 envelope: &InterchangeEnvelope,
1219 messages: &[InterchangeMessage],
1220 options: &EnvelopeOptions,
1221 ) -> Result<String, MapperError> {
1222 self.render_interchange(envelope, messages, EntrySegmentCheck::Refuse, options)
1223 }
1224
1225 fn render_interchange(
1226 &self,
1227 envelope: &InterchangeEnvelope,
1228 messages: &[InterchangeMessage],
1229 check: EntrySegmentCheck,
1230 options: &EnvelopeOptions,
1231 ) -> Result<String, MapperError> {
1232 let delimiters = edifact_primitives::EdifactDelimiters::default();
1233 let sep = delimiters.component as char;
1234 let elem = delimiters.element as char;
1235 let seg_term = delimiters.segment as char;
1236
1237 let mut output = String::new();
1238
1239 // UNA — Service string advice. Omitted on request: an input that
1240 // carried none should not gain one (issue #161).
1241 if options.emit_una {
1242 output.push_str(&format!(
1243 "UNA{}{}{}{}{}{}",
1244 sep, // component separator
1245 elem, // element separator
1246 delimiters.decimal as char, // decimal notation
1247 delimiters.release as char, // release/escape character
1248 ' ', // reserved (space)
1249 seg_term, // segment terminator
1250 ));
1251 }
1252
1253 // UNB — Interchange header. The caller's date and time when it has
1254 // them, the clock otherwise.
1255 //
1256 // Checked here rather than in the builder: `datum_zeit` returns `Self`
1257 // so it cannot fail without spoiling the chaining, and this is the only
1258 // place that knows both values are present. A width-and-digits check is
1259 // all that is possible and all that is needed — it cannot know whether
1260 // a date is the right one, but it catches the two mistakes that happen,
1261 // an ISO date and a human-formatted time.
1262 check_unb_field("datum", "yymmdd", 6, options.datum.as_deref())?;
1263 check_unb_field("zeit", "hhmm", 4, options.zeit.as_deref())?;
1264
1265 let now = chrono::Utc::now();
1266 let date_str = options
1267 .datum
1268 .clone()
1269 .unwrap_or_else(|| now.format("%y%m%d").to_string());
1270 let time_str = options
1271 .zeit
1272 .clone()
1273 .unwrap_or_else(|| now.format("%H%M").to_string());
1274 let sender = &envelope.sender;
1275 let receiver = &envelope.receiver;
1276 let interchange_ref = &envelope.interchange_ref;
1277 output.push_str(&format!(
1278 "UNB{elem}UNOC{sep}3{elem}{sid}{sep}{sq}{elem}{rid}{sep}{rq}{elem}{date_str}{sep}{time_str}{elem}{interchange_ref}{seg_term}",
1279 sid = sender.id,
1280 sq = sender.qualifier,
1281 rid = receiver.id,
1282 rq = receiver.qualifier,
1283 ));
1284
1285 let mut message_count = 0u32;
1286
1287 for msg in messages {
1288 let meta = self.message_metadata(&msg.fv, &msg.variant)?;
1289
1290 // Generate body segments
1291 let body = self.render_message_body(
1292 &msg.msg_stammdaten,
1293 &msg.tx_stammdaten,
1294 &msg.fv,
1295 &msg.variant,
1296 &msg.pid,
1297 check,
1298 )?;
1299
1300 // Count segments in body (split by segment terminator, filter empty)
1301 let body_seg_count = body
1302 .split(seg_term)
1303 .filter(|s: &&str| !s.is_empty())
1304 .count();
1305 // UNH + body segments + UNT = total segment count
1306 let segment_count = body_seg_count + 2;
1307
1308 // UNH — Message header
1309 output.push_str(&format!(
1310 "UNH{elem}{ref}{elem}{msg_type}{sep}D{sep}{release}{sep}UN{sep}{assoc}{seg_term}",
1311 ref = msg.message_ref,
1312 msg_type = meta.message_type,
1313 release = meta.release,
1314 assoc = meta.association_code,
1315 ));
1316
1317 // Body segments
1318 output.push_str(&body);
1319
1320 // UNT — Message trailer
1321 output.push_str(&format!(
1322 "UNT{elem}{segment_count}{elem}{ref}{seg_term}",
1323 ref = msg.message_ref,
1324 ));
1325
1326 message_count += 1;
1327 }
1328
1329 // UNZ — Interchange trailer
1330 output.push_str(&format!(
1331 "UNZ{elem}{message_count}{elem}{interchange_ref}{seg_term}",
1332 ));
1333
1334 Ok(output)
1335 }
1336
1337 /// List all format versions currently loaded in memory.
1338 pub fn loaded_format_versions(&self) -> Vec<String> {
1339 self.bundles.lock().unwrap().keys().cloned().collect()
1340 }
1341
1342 /// List all variants available in a format version's bundle.
1343 ///
1344 /// Loads the bundle if not already loaded.
1345 pub fn variants(&self, fv: &str) -> Result<Vec<String>, MapperError> {
1346 self.ensure_bundle_loaded(fv)?;
1347 let bundles = self.bundles.lock().unwrap();
1348 let bundle = bundles.get(fv).unwrap();
1349 Ok(bundle.variants.keys().cloned().collect())
1350 }
1351}
1352
1353/// Metadata about a message type needed for constructing UNH segments.
1354#[derive(Debug, Clone)]
1355pub struct MessageMetadata {
1356 /// EDIFACT message type (e.g., `"UTILMD"`, `"MSCONS"`).
1357 pub message_type: String,
1358 /// UN/EDIFACT directory release code (e.g., `"11A"`, `"04B"`).
1359 pub release: String,
1360 /// Association-assigned code / MIG version (e.g., `"S2.1"`, `"2.4c"`).
1361 pub association_code: String,
1362}
1363
1364/// Envelope parameters for [`Mapper::to_edifact_interchange`].
1365#[derive(Debug, Clone)]
1366pub struct InterchangeEnvelope {
1367 /// Sender party (UNB S002).
1368 pub sender: EdifactParty,
1369 /// Receiver party (UNB S003).
1370 pub receiver: EdifactParty,
1371 /// Unique interchange reference (UNB 0020 / UNZ 0020).
1372 pub interchange_ref: String,
1373}
1374
1375/// Reject an `UNB` date or time that is not `digits` digits.
1376///
1377/// `None` means the caller did not supply one and the clock is used, which is
1378/// always well formed.
1379fn check_unb_field(
1380 field: &'static str,
1381 expected: &'static str,
1382 digits: usize,
1383 value: Option<&str>,
1384) -> Result<(), MapperError> {
1385 let Some(value) = value else {
1386 return Ok(());
1387 };
1388 if value.len() == digits && value.bytes().all(|b| b.is_ascii_digit()) {
1389 return Ok(());
1390 }
1391 Err(MapperError::MalformedEnvelopeDateTime {
1392 field,
1393 expected,
1394 digits,
1395 value: value.to_string(),
1396 })
1397}
1398
1399/// How [`Mapper::to_edifact_interchange_with`] builds the interchange envelope.
1400///
1401/// The default is to **regenerate**: emit a `UNA` service string advice and
1402/// stamp the `UNB` date and time from the clock. That is right for a re-send,
1403/// and it is what [`Mapper::to_edifact_interchange`] does.
1404///
1405/// It is wrong for a caller comparing a render against its input, because the
1406/// two differences are not about the message (issue #161). Such a caller has
1407/// the original — the forward direction hands it back as `Interchangedaten` —
1408/// and can ask for it here.
1409///
1410/// ```ignore
1411/// let options = EnvelopeOptions::default()
1412/// .emit_una(false)
1413/// .datum_zeit_from(&interchange.interchangedaten);
1414/// ```
1415///
1416/// # What this cannot reproduce
1417///
1418/// Non-default delimiters. The whole render — envelope and body alike — uses
1419/// [`EdifactDelimiters::default`], so an input whose `UNA` declared other
1420/// delimiters cannot be reproduced, and `emit_una(true)` always advertises the
1421/// defaults. Suppressing the `UNA` is honest about that; claiming delimiters
1422/// the body does not honour would not be.
1423///
1424/// [`EdifactDelimiters::default`]: edifact_primitives::EdifactDelimiters::default
1425#[derive(Debug, Clone)]
1426pub struct EnvelopeOptions {
1427 emit_una: bool,
1428 datum: Option<String>,
1429 zeit: Option<String>,
1430}
1431
1432impl Default for EnvelopeOptions {
1433 fn default() -> Self {
1434 Self {
1435 emit_una: true,
1436 datum: None,
1437 zeit: None,
1438 }
1439 }
1440}
1441
1442impl EnvelopeOptions {
1443 /// Whether to emit the `UNA` service string advice. Default `true`.
1444 ///
1445 /// An input that carried no `UNA` gains one unless this is `false`.
1446 pub fn emit_una(mut self, emit: bool) -> Self {
1447 self.emit_una = emit;
1448 self
1449 }
1450
1451 /// Interchange date (`yymmdd`) and time (`hhmm`) for `UNB`, instead of the
1452 /// clock.
1453 ///
1454 /// Both go into the header verbatim. A value that is not the right number
1455 /// of digits is refused when the interchange is rendered — with
1456 /// [`MapperError::MalformedEnvelopeDateTime`], not silently — because `UNB`
1457 /// is the segment whose defects surface at the receiving gateway rather
1458 /// than anywhere the sender looks.
1459 pub fn datum_zeit(mut self, datum: impl Into<String>, zeit: impl Into<String>) -> Self {
1460 self.datum = Some(datum.into());
1461 self.zeit = Some(zeit.into());
1462 self
1463 }
1464
1465 /// Take the `UNB` date and time from the `Interchangedaten` the forward
1466 /// direction produced. Fields it does not carry are left to the clock.
1467 pub fn datum_zeit_from(mut self, daten: &mig_bo4e::model::Interchangedaten) -> Self {
1468 self.datum = daten.datum.clone();
1469 self.zeit = daten.zeit.clone();
1470 self
1471 }
1472}
1473
1474/// An EDIFACT interchange party (sender or receiver) with codelist qualifier.
1475#[derive(Debug, Clone)]
1476pub struct EdifactParty {
1477 /// Party identification (e.g., MP-ID `"9900000000003"` or GLN `"4045458000000"`).
1478 pub id: String,
1479 /// Codelist qualifier: `"500"` = BDEW, `"14"` = GS1/EAN.
1480 pub qualifier: String,
1481}
1482
1483impl EdifactParty {
1484 /// Create a party with BDEW codelist qualifier (500).
1485 pub fn bdew(id: &str) -> Self {
1486 Self {
1487 id: id.to_string(),
1488 qualifier: "500".to_string(),
1489 }
1490 }
1491
1492 /// Create a party with GS1/EAN codelist qualifier (14).
1493 pub fn gs1(id: &str) -> Self {
1494 Self {
1495 id: id.to_string(),
1496 qualifier: "14".to_string(),
1497 }
1498 }
1499}
1500
1501/// A single message to include in an interchange built by
1502/// [`Mapper::to_edifact_interchange`].
1503#[derive(Debug, Clone)]
1504pub struct InterchangeMessage {
1505 /// Unique message reference number (used in UNH/UNT).
1506 pub message_ref: String,
1507 /// Message-level stammdaten (e.g., marktteilnehmer).
1508 pub msg_stammdaten: serde_json::Value,
1509 /// Transaction-level stammdaten (one per transaction).
1510 pub tx_stammdaten: Vec<serde_json::Value>,
1511 /// Format version (e.g., `"FV2604"`).
1512 pub fv: String,
1513 /// Message variant (e.g., `"UTILMD_Strom"`).
1514 pub variant: String,
1515 /// Pruefidentifikator (e.g., `"55001"`).
1516 pub pid: String,
1517}
1518
1519/// What rendering does with a group instance that lacks its MIG entry segment.
1520#[derive(Debug, Clone, Copy)]
1521enum EntrySegmentCheck {
1522 /// Fail with [`MapperError::MissingGroupEntrySegment`].
1523 Refuse,
1524 /// Render it anyway (for validation, which reports the defect).
1525 Render,
1526}
1527
1528/// Find the mapping definitions for a group that rendered without its entry
1529/// segment, for the error message: the BO4E entities they fill, and the BO4E
1530/// fields the entry segment is built from (the data the caller has to supply).
1531///
1532/// `source_path` comes from the filtered MIG, where the variant qualifier of a
1533/// group may be absent (a PID with a single variant, or an instance whose
1534/// variant is unknown because its entry segment is missing: `sg4.sg8.sg10`)
1535/// while definitions carry one (`sg4.sg8_z03.sg10`), or the other way round.
1536/// An unqualified part therefore matches any variant of the same group.
1537fn describe_entry_segment_mappings<'d>(
1538 definition_sets: impl IntoIterator<Item = &'d [mig_bo4e::definition::MappingDefinition]>,
1539 source_path: &str,
1540 entry_segment: &str,
1541) -> (Vec<String>, Vec<String>) {
1542 fn qualifies(unqualified: &str, qualified: &str) -> bool {
1543 !unqualified.contains('_')
1544 && qualified.len() > unqualified.len()
1545 && qualified.is_char_boundary(unqualified.len())
1546 && qualified[..unqualified.len()].eq_ignore_ascii_case(unqualified)
1547 && qualified.as_bytes()[unqualified.len()] == b'_'
1548 }
1549 fn part_matches(mig_part: &str, def_part: &str) -> bool {
1550 def_part.eq_ignore_ascii_case(mig_part)
1551 || qualifies(mig_part, def_part)
1552 || qualifies(def_part, mig_part)
1553 }
1554 let mig_parts: Vec<&str> = source_path.split('.').collect();
1555
1556 let mut entities: Vec<String> = Vec::new();
1557 let mut entry_fields: Vec<String> = Vec::new();
1558 for def in definition_sets.into_iter().flatten() {
1559 let Some(def_path) = def.meta.source_path.as_deref() else {
1560 continue;
1561 };
1562 let def_parts: Vec<&str> = def_path.split('.').collect();
1563 if def_parts.len() != mig_parts.len()
1564 || !mig_parts
1565 .iter()
1566 .zip(&def_parts)
1567 .all(|(m, d)| part_matches(m, d))
1568 {
1569 continue;
1570 }
1571 if !entities.contains(&def.meta.entity) {
1572 entities.push(def.meta.entity.clone());
1573 }
1574 for (path, mapping) in &def.fields {
1575 let tag = path
1576 .split(['.', '['])
1577 .next()
1578 .unwrap_or_default()
1579 .to_ascii_uppercase();
1580 let target = match mapping {
1581 mig_bo4e::definition::FieldMapping::Simple(t) => t.as_str(),
1582 mig_bo4e::definition::FieldMapping::Structured(f) => f.target.as_str(),
1583 mig_bo4e::definition::FieldMapping::Nested(_) => continue,
1584 };
1585 if tag == entry_segment && !target.is_empty() {
1586 let field = format!("{}.{}", def.meta.entity, target);
1587 if !entry_fields.contains(&field) {
1588 entry_fields.push(field);
1589 }
1590 }
1591 }
1592 }
1593 (entities, entry_fields)
1594}
1595
1596/// UN/EDIFACT directory release code for a message type.
1597///
1598/// These are stable per-message-type constants from the BDEW/DVGW specifications.
1599fn release_code_for_message_type(msg_type: &str) -> String {
1600 mig_bo4e::model::release_code_for_message_type(msg_type).to_string()
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use super::*;
1606 use std::path::Path;
1607
1608 fn data_dir() -> Option<std::path::PathBuf> {
1609 // Try dist/ first (pre-built data bundles), then cache/mappings/
1610 let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist");
1611 if dist.join("edifact-data-FV2504.bin").exists() {
1612 return Some(dist);
1613 }
1614 let cache = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cache/mappings");
1615 if cache.join("FV2504").exists() {
1616 return Some(cache);
1617 }
1618 eprintln!("Skipping test: no DataBundle files found");
1619 None
1620 }
1621
1622 #[test]
1623 fn test_to_edifact_produces_edifact_output() {
1624 let Some(data_dir) = data_dir() else {
1625 return;
1626 };
1627 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1628
1629 let msg_stammdaten = serde_json::json!({
1630 "marktteilnehmer": [{
1631 "marktrolle": "MS",
1632 "rollencodenummer": "9900123456789",
1633 "codepflegeCode": "293"
1634 }]
1635 });
1636 let tx_stammdaten = serde_json::json!({
1637 "prozessdaten": {
1638 "pruefidentifikator": "55001",
1639 "vorgangId": "ABC123",
1640 "transaktionsgrund": "E01"
1641 }
1642 });
1643
1644 let result = mapper.to_edifact(
1645 &msg_stammdaten,
1646 &[tx_stammdaten],
1647 "FV2504",
1648 "UTILMD_Strom",
1649 "55001",
1650 );
1651 assert!(result.is_ok(), "to_edifact failed: {:?}", result.err());
1652 let edifact = result.unwrap();
1653 assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1654 // Should produce NAD segment from marktteilnehmer
1655 assert!(edifact.contains("NAD"), "Should contain NAD segment");
1656 // Should produce IDE segment from prozessdaten
1657 assert!(edifact.contains("IDE"), "Should contain IDE segment");
1658 }
1659
1660 #[test]
1661 fn test_to_edifact_struct_produces_edifact_output() {
1662 let Some(data_dir) = data_dir() else {
1663 return;
1664 };
1665 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1666
1667 let nachricht = serde_json::json!({
1668 "stammdaten": {
1669 "marktteilnehmer": [{
1670 "marktrolle": "MS",
1671 "rollencodenummer": "9900123456789",
1672 "codepflegeCode": "293"
1673 }]
1674 },
1675 "transaktionen": [{
1676 "prozessdaten": {
1677 "pruefidentifikator": "55001",
1678 "vorgangId": "ABC123"
1679 }
1680 }]
1681 });
1682
1683 let result = mapper.to_edifact_struct(&nachricht, "FV2504", "UTILMD_Strom", "55001");
1684 assert!(
1685 result.is_ok(),
1686 "to_edifact_struct failed: {:?}",
1687 result.err()
1688 );
1689 let edifact = result.unwrap();
1690 assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1691 }
1692
1693 #[test]
1694 fn test_to_edifact_invalid_fv_returns_error() {
1695 let Some(data_dir) = data_dir() else {
1696 return;
1697 };
1698 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1699
1700 let result = mapper.to_edifact(
1701 &serde_json::json!({}),
1702 &[serde_json::json!({})],
1703 "FV9999",
1704 "UTILMD_Strom",
1705 "55001",
1706 );
1707 assert!(result.is_err());
1708 }
1709
1710 #[test]
1711 fn test_to_edifact_invalid_variant_returns_error() {
1712 let Some(data_dir) = data_dir() else {
1713 return;
1714 };
1715 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1716
1717 let result = mapper.to_edifact(
1718 &serde_json::json!({}),
1719 &[serde_json::json!({})],
1720 "FV2504",
1721 "NONEXISTENT",
1722 "55001",
1723 );
1724 assert!(result.is_err());
1725 }
1726
1727 #[test]
1728 fn test_to_edifact_invalid_pid_returns_error() {
1729 let Some(data_dir) = data_dir() else {
1730 return;
1731 };
1732 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1733
1734 let result = mapper.to_edifact(
1735 &serde_json::json!({}),
1736 &[serde_json::json!({})],
1737 "FV2504",
1738 "UTILMD_Strom",
1739 "99999",
1740 );
1741 assert!(result.is_err());
1742 }
1743
1744 #[test]
1745 fn test_association_code() {
1746 let Some(data_dir) = data_dir() else {
1747 return;
1748 };
1749 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1750
1751 let code = mapper.association_code("FV2504", "UTILMD_Strom").unwrap();
1752 assert_eq!(code, "S2.1");
1753
1754 let code = mapper.association_code("FV2504", "MSCONS").unwrap();
1755 assert_eq!(code, "2.4c");
1756 }
1757
1758 #[test]
1759 fn test_message_metadata() {
1760 let Some(data_dir) = data_dir() else {
1761 return;
1762 };
1763 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1764
1765 let meta = mapper.message_metadata("FV2504", "UTILMD_Strom").unwrap();
1766 assert_eq!(meta.message_type, "UTILMD");
1767 assert_eq!(meta.release, "11A");
1768 assert_eq!(meta.association_code, "S2.1");
1769 }
1770
1771 #[test]
1772 fn test_to_edifact_interchange() {
1773 let Some(data_dir) = data_dir() else {
1774 return;
1775 };
1776 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1777
1778 let result = mapper.to_edifact_interchange(
1779 &InterchangeEnvelope {
1780 sender: EdifactParty::bdew("9900000000003"),
1781 receiver: EdifactParty::bdew("9900000000001"),
1782 interchange_ref: "REF001".to_string(),
1783 },
1784 &[InterchangeMessage {
1785 message_ref: "MSG001".to_string(),
1786 msg_stammdaten: serde_json::json!({
1787 "marktteilnehmer": [{
1788 "marktrolle": "MS",
1789 "rollencodenummer": "9900123456789",
1790 "codepflegeCode": "293"
1791 }]
1792 }),
1793 tx_stammdaten: vec![serde_json::json!({
1794 "prozessdaten": {
1795 "pruefidentifikator": "55001",
1796 "vorgangId": "ABC123",
1797 "transaktionsgrund": "E01"
1798 }
1799 })],
1800 fv: "FV2504".to_string(),
1801 variant: "UTILMD_Strom".to_string(),
1802 pid: "55001".to_string(),
1803 }],
1804 );
1805 assert!(
1806 result.is_ok(),
1807 "to_edifact_interchange failed: {:?}",
1808 result.err()
1809 );
1810 let edifact = result.unwrap();
1811
1812 // Verify envelope structure
1813 assert!(edifact.starts_with("UNA:+.? '"), "Should start with UNA");
1814 assert!(
1815 edifact.contains("UNB+UNOC:3+9900000000003:500+9900000000001:500+"),
1816 "Should contain UNB with sender/receiver"
1817 );
1818 assert!(
1819 edifact.contains("UNH+MSG001+UTILMD:D:11A:UN:S2.1'"),
1820 "Should contain UNH with correct S009"
1821 );
1822 assert!(edifact.contains("NAD"), "Should contain body NAD segment");
1823 assert!(edifact.contains("UNT+"), "Should contain UNT");
1824 assert!(
1825 edifact.contains("+MSG001'"),
1826 "UNT should reference message ref"
1827 );
1828 assert!(
1829 edifact.contains("UNZ+1+REF001'"),
1830 "Should contain UNZ with count and ref"
1831 );
1832 }
1833
1834 #[test]
1835 fn test_detect_pid_from_rff_z13() {
1836 let Some(data_dir) = data_dir() else {
1837 return;
1838 };
1839 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1840
1841 let edifact = "\
1842 UNB+UNOC:3+9978842000002:500+9900269000000:500+250331:1329+REF001'\
1843 UNH+MSG001+UTILMD:D:11A:UN:S2.1'\
1844 BGM+E01+DOC001'\
1845 DTM+137:202503311329?+00:303'\
1846 NAD+MS+9978842000002::293'\
1847 NAD+MR+9900269000000::293'\
1848 IDE+24+TX001'\
1849 DTM+92:202505312200?+00:303'\
1850 DTM+93:202512312300?+00:303'\
1851 STS+7++E01+ZW4+E03'\
1852 LOC+Z16+12345678900'\
1853 RFF+Z13:55001'\
1854 UNT+12+MSG001'\
1855 UNZ+1+REF001'";
1856
1857 let pid = mapper.detect_pid(edifact).unwrap();
1858 assert_eq!(pid, "55001");
1859 }
1860
1861 #[test]
1862 fn test_detect_pid_no_messages_returns_error() {
1863 let Some(data_dir) = data_dir() else {
1864 return;
1865 };
1866 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1867
1868 let edifact = "UNB+UNOC:3+SENDER:500+RECEIVER:500+250401:1200+REF'\
1869 UNZ+0+REF'";
1870 assert!(mapper.detect_pid(edifact).is_err());
1871 }
1872
1873 #[test]
1874 fn test_list_pids_returns_entries() {
1875 let Some(data_dir) = data_dir() else {
1876 return;
1877 };
1878 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir)).unwrap();
1879 let pids = mapper.list_pids().expect("list_pids should succeed");
1880 assert!(!pids.is_empty(), "should return at least one PID");
1881 assert!(
1882 pids.iter().any(|p| p.pid == "55001"),
1883 "should include PID 55001"
1884 );
1885 assert!(
1886 pids.iter().any(|p| p.fv == "FV2504"),
1887 "should include FV2504"
1888 );
1889 assert!(
1890 pids.iter().any(|p| p.variant == "UTILMD_Strom"),
1891 "should include UTILMD_Strom"
1892 );
1893 }
1894
1895 #[test]
1896 fn test_pid_requirements_returns_requirements() {
1897 let Some(data_dir) = data_dir() else {
1898 return;
1899 };
1900 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1901
1902 let req = mapper
1903 .pid_requirements("FV2504", "UTILMD_Strom", "55001")
1904 .expect("pid_requirements should succeed");
1905
1906 assert_eq!(req.pid, "55001");
1907 assert!(
1908 !req.entities.is_empty(),
1909 "55001 should have at least one entity"
1910 );
1911 assert!(
1912 req.entities.iter().any(|e| e.entity == "Prozessdaten"),
1913 "55001 should have a Prozessdaten entity"
1914 );
1915 }
1916}