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