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