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