Skip to main content

edifact_mapper/
mapper.rs

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