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