Skip to main content

edifact_mapper/
mapper.rs

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