Skip to main content

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}