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