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