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