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.validate_edifact_inner(edifact, fv, None, level)
698    }
699
700    /// [`validate_edifact`], but validating against a PID the caller already knows.
701    ///
702    /// Use this when the PID comes from somewhere other than the message — a form,
703    /// a route, a job definition. It skips PID detection, which only works for
704    /// message types that carry the Prüfidentifikator in `RFF+Z13` (UTILMD); for
705    /// ORDERS, MSCONS, IFTSTA and the rest, detection cannot recover a PID that the
706    /// caller already has.
707    ///
708    /// [`validate_edifact`]: Self::validate_edifact
709    pub fn validate_edifact_for_pid(
710        &self,
711        edifact: &str,
712        fv: &str,
713        variant: &str,
714        pid: &str,
715        level: automapper_validation::ValidationLevel,
716    ) -> Result<automapper_validation::ValidationReport, MapperError> {
717        self.validate_edifact_inner(edifact, fv, Some((variant, pid)), level)
718    }
719
720    fn validate_edifact_inner(
721        &self,
722        edifact: &str,
723        fv: &str,
724        known: Option<(&str, &str)>,
725        level: automapper_validation::ValidationLevel,
726    ) -> Result<automapper_validation::ValidationReport, MapperError> {
727        self.ensure_bundle_loaded(fv)?;
728        let bundles = self.bundles.lock().unwrap();
729        let bundle = bundles.get(fv).unwrap();
730
731        // Tokenize → split → first message (same as `detect_pid`).
732        let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
733        let chunks = mig_assembly::split_messages(segments)?;
734        let msg_chunk = chunks.messages.first().ok_or_else(|| {
735            MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
736                "No messages found in EDIFACT content".to_string(),
737            ))
738        })?;
739
740        // Resolve the PID: detect it when the caller doesn't know it, and resolve
741        // the owning variant from the bundle. When the caller does know both (the
742        // `validate_bo4e` path), take them as given — detection only works for
743        // message types that carry the PID in RFF+Z13 (UTILMD), so re-deriving a
744        // PID the caller already supplied would fail on ORDERS, MSCONS, IFTSTA, …
745        let (pid, variant, vc) = match known {
746            Some((variant, pid)) => {
747                let vc = bundle
748                    .variant(variant)
749                    .ok_or_else(|| MapperError::VariantNotFound {
750                        fv: fv.to_string(),
751                        variant: variant.to_string(),
752                    })?;
753                (pid.to_string(), variant.to_string(), vc)
754            }
755            None => {
756                let pid = mig_assembly::pid_detect::detect_pid(&msg_chunk.message_segments())
757                    .map_err(MapperError::Assembly)?;
758                let pid_key = format!("pid_{pid}");
759                let (variant, vc) = bundle
760                    .variants
761                    .iter()
762                    .find(|(_, vc)| vc.pid_ahb_workflows.contains_key(&pid_key))
763                    .ok_or_else(|| MapperError::PidNotFound {
764                        fv: fv.to_string(),
765                        variant: "?".to_string(),
766                        pid: pid.clone(),
767                    })?;
768                (pid, variant.clone(), vc)
769            }
770        };
771        let pid_key = format!("pid_{pid}");
772
773        let workflow =
774            vc.pid_ahb_workflows
775                .get(&pid_key)
776                .ok_or_else(|| MapperError::PidNotFound {
777                    fv: fv.to_string(),
778                    variant: variant.clone(),
779                    pid: pid.clone(),
780                })?;
781        let filtered_mig = vc
782            .filtered_mig(&pid)
783            .ok_or_else(|| MapperError::NoMigSchema {
784                fv: fv.to_string(),
785                variant: variant.clone(),
786            })?;
787
788        // Segments the validator sees: this message's body for the filtered MIG,
789        // plus the interchange UNZ when the MIG covers it (e.g. MSCONS).
790        let mut all_segments = msg_chunk.segments_for_mig(&filtered_mig);
791        if filtered_mig.segments.iter().any(|s| s.id == "UNZ") {
792            if let Some(unz) = &chunks.unz {
793                all_segments.push(unz.clone());
794            }
795        }
796
797        // Same evaluator resolution + fallback the v2 route uses. The explicit
798        // target type lets each arm coerce (Box<dyn> → Arc<dyn>; Arc<Concrete> →
799        // Arc<dyn> unsize) — a `.map(Arc::from)` chain can't infer that.
800        let evaluator: std::sync::Arc<dyn automapper_validation::ConditionEvaluator> =
801            match crate::evaluator_factory::create_evaluator(&variant, fv) {
802                Some(boxed) => std::sync::Arc::from(boxed),
803                None => std::sync::Arc::new(
804                    automapper_validation::UtilmdStromConditionEvaluatorFV2504::default(),
805                ),
806            };
807        let external = automapper_validation::eval::NoOpExternalProvider;
808
809        let mut report = automapper_validation::validate_edifact_message(
810            &all_segments,
811            &filtered_mig,
812            workflow,
813            evaluator,
814            &external,
815            level,
816        );
817
818        // Enrich findings with BO4E field paths so consumers can map the
819        // segment-path findings back to the BO4E form (same enrichment the v2
820        // `validate-bo4e` route applies). Sourced entirely from the bundle: the
821        // combined mapping defs, the PID-filtered MIG, and a reverse resolver
822        // built from the full MIG — no generated schema files needed.
823        if let (Some(mig), Some(defs)) = (vc.mig_schema.as_ref(), vc.combined_defs.get(&pid_key)) {
824            let reverse = mig_bo4e::path_resolver::ReversePathResolver::from_mig(mig);
825            let field_index =
826                mig_bo4e::Bo4eFieldIndex::build_with_resolver(defs, &filtered_mig, &reverse);
827            report.enrich_bo4e_paths(|path, hint| field_index.resolve(path, hint));
828        }
829
830        Ok(report)
831    }
832
833    /// Validate BO4E JSON against the AHB rules of its Prüfidentifikator.
834    ///
835    /// This is [`validate_edifact`] with a reverse-mapping front end: the BO4E
836    /// input is rendered to a complete EDIFACT interchange
837    /// ([`to_edifact_interchange`]) and that interchange is validated. Because it
838    /// is literally the same call, the findings are the ones the EDIFACT
839    /// validation reports for the message this BO4E describes — including the
840    /// `bo4e_path` enrichment that points each finding back at the BO4E field it
841    /// came from. Callers working in BO4E (forms, assistants) therefore do not
842    /// need their own EDIFACT-path-to-BO4E-path translation.
843    ///
844    /// `envelope` fills UNB/UNZ. Pass `None` unless the message type's MIG covers
845    /// the interchange envelope (e.g. MSCONS) — for the others the envelope is
846    /// outside the AHB and a neutral placeholder is used.
847    ///
848    /// Two classes of finding cannot appear here, because the BO4E input has no
849    /// counterpart for them: the UNT segment-count check (the trailer is
850    /// regenerated) and skipped-unknown-segment diagnostics (segments outside the
851    /// AHB have no BO4E representation).
852    ///
853    /// [`validate_edifact`]: Self::validate_edifact
854    /// [`to_edifact_interchange`]: Self::to_edifact_interchange
855    pub fn validate_bo4e(
856        &self,
857        msg_stammdaten: &serde_json::Value,
858        tx_stammdaten: &[serde_json::Value],
859        fv: &str,
860        variant: &str,
861        pid: &str,
862        envelope: Option<&InterchangeEnvelope>,
863        level: automapper_validation::ValidationLevel,
864    ) -> Result<automapper_validation::ValidationReport, MapperError> {
865        let placeholder;
866        let envelope = match envelope {
867            Some(e) => e,
868            None => {
869                placeholder = InterchangeEnvelope {
870                    sender: EdifactParty::bdew("9900000000001"),
871                    receiver: EdifactParty::bdew("9900000000002"),
872                    interchange_ref: "1".to_string(),
873                };
874                &placeholder
875            }
876        };
877
878        let edifact = self.to_edifact_interchange(
879            envelope,
880            &[InterchangeMessage {
881                message_ref: "1".to_string(),
882                msg_stammdaten: msg_stammdaten.clone(),
883                tx_stammdaten: tx_stammdaten.to_vec(),
884                fv: fv.to_string(),
885                variant: variant.to_string(),
886                pid: pid.to_string(),
887            }],
888        )?;
889
890        // The PID is given, not detected: for every message type but UTILMD the
891        // rendered EDIFACT carries no RFF+Z13 to detect it from.
892        self.validate_edifact_for_pid(&edifact, fv, variant, pid, level)
893    }
894
895    /// Get the UNH association code for a variant (e.g., `"S2.1"`, `"2.4c"`).
896    ///
897    /// This is the version string from the MIG schema, used as the last component
898    /// of the UNH S009 composite: `UTILMD:D:11A:UN:S2.1`.
899    ///
900    /// # Example
901    /// ```ignore
902    /// let code = mapper.association_code("FV2604", "UTILMD_Strom")?;
903    /// assert_eq!(code, "S2.1");
904    /// ```
905    pub fn association_code(&self, fv: &str, variant: &str) -> Result<String, MapperError> {
906        let meta = self.message_metadata(fv, variant)?;
907        Ok(meta.association_code)
908    }
909
910    /// Get full message metadata for a variant, including the UNH S009 components.
911    ///
912    /// Returns the message type, UN/EDIFACT release code, and association code
913    /// needed to construct UNH segments.
914    pub fn message_metadata(
915        &self,
916        fv: &str,
917        variant: &str,
918    ) -> Result<MessageMetadata, MapperError> {
919        self.ensure_bundle_loaded(fv)?;
920        let bundles = self.bundles.lock().unwrap();
921        let bundle = bundles.get(fv).unwrap();
922        let vc = bundle
923            .variant(variant)
924            .ok_or_else(|| MapperError::VariantNotFound {
925                fv: fv.to_string(),
926                variant: variant.to_string(),
927            })?;
928        let mig = vc
929            .mig_schema
930            .as_ref()
931            .ok_or_else(|| MapperError::NoMigSchema {
932                fv: fv.to_string(),
933                variant: variant.to_string(),
934            })?;
935        Ok(MessageMetadata {
936            message_type: mig.message_type.clone(),
937            release: release_code_for_message_type(&mig.message_type),
938            association_code: mig.version.clone(),
939        })
940    }
941
942    /// Convert BO4E JSON to a complete EDIFACT interchange with envelope segments.
943    ///
944    /// Produces a full interchange including UNA, UNB, UNH, message body, UNT, and UNZ.
945    ///
946    /// # Example
947    /// ```ignore
948    /// let edifact = mapper.to_edifact_interchange(
949    ///     &InterchangeEnvelope {
950    ///         sender: EdifactParty::bdew("9900000000003"),
951    ///         receiver: EdifactParty::bdew("9900000000001"),
952    ///         interchange_ref: "REF001".to_string(),
953    ///     },
954    ///     &[InterchangeMessage {
955    ///         message_ref: "MSG001".to_string(),
956    ///         msg_stammdaten: serde_json::json!({"marktteilnehmer": []}),
957    ///         tx_stammdaten: vec![serde_json::json!({"prozessdaten": {"pruefidentifikator": "55001"}})],
958    ///         fv: "FV2604".to_string(),
959    ///         variant: "UTILMD_Strom".to_string(),
960    ///         pid: "55001".to_string(),
961    ///     }],
962    /// )?;
963    /// assert!(edifact.starts_with("UNA:+.? '"));
964    /// ```
965    pub fn to_edifact_interchange(
966        &self,
967        envelope: &InterchangeEnvelope,
968        messages: &[InterchangeMessage],
969    ) -> Result<String, MapperError> {
970        let delimiters = edifact_primitives::EdifactDelimiters::default();
971        let sep = delimiters.component as char;
972        let elem = delimiters.element as char;
973        let seg_term = delimiters.segment as char;
974
975        let mut output = String::new();
976
977        // UNA — Service string advice
978        output.push_str(&format!(
979            "UNA{}{}{}{}{}{}",
980            sep,                        // component separator
981            elem,                       // element separator
982            delimiters.decimal as char, // decimal notation
983            delimiters.release as char, // release/escape character
984            ' ',                        // reserved (space)
985            seg_term,                   // segment terminator
986        ));
987
988        // UNB — Interchange header
989        let now = chrono::Utc::now();
990        let date_str = now.format("%y%m%d").to_string();
991        let time_str = now.format("%H%M").to_string();
992        let sender = &envelope.sender;
993        let receiver = &envelope.receiver;
994        let interchange_ref = &envelope.interchange_ref;
995        output.push_str(&format!(
996            "UNB{elem}UNOC{sep}3{elem}{sid}{sep}{sq}{elem}{rid}{sep}{rq}{elem}{date_str}{sep}{time_str}{elem}{interchange_ref}{seg_term}",
997            sid = sender.id,
998            sq = sender.qualifier,
999            rid = receiver.id,
1000            rq = receiver.qualifier,
1001        ));
1002
1003        let mut message_count = 0u32;
1004
1005        for msg in messages {
1006            let meta = self.message_metadata(&msg.fv, &msg.variant)?;
1007
1008            // Generate body segments
1009            let body = self.to_edifact(
1010                &msg.msg_stammdaten,
1011                &msg.tx_stammdaten,
1012                &msg.fv,
1013                &msg.variant,
1014                &msg.pid,
1015            )?;
1016
1017            // Count segments in body (split by segment terminator, filter empty)
1018            let body_seg_count = body
1019                .split(seg_term)
1020                .filter(|s: &&str| !s.is_empty())
1021                .count();
1022            // UNH + body segments + UNT = total segment count
1023            let segment_count = body_seg_count + 2;
1024
1025            // UNH — Message header
1026            output.push_str(&format!(
1027                "UNH{elem}{ref}{elem}{msg_type}{sep}D{sep}{release}{sep}UN{sep}{assoc}{seg_term}",
1028                ref = msg.message_ref,
1029                msg_type = meta.message_type,
1030                release = meta.release,
1031                assoc = meta.association_code,
1032            ));
1033
1034            // Body segments
1035            output.push_str(&body);
1036
1037            // UNT — Message trailer
1038            output.push_str(&format!(
1039                "UNT{elem}{segment_count}{elem}{ref}{seg_term}",
1040                ref = msg.message_ref,
1041            ));
1042
1043            message_count += 1;
1044        }
1045
1046        // UNZ — Interchange trailer
1047        output.push_str(&format!(
1048            "UNZ{elem}{message_count}{elem}{interchange_ref}{seg_term}",
1049        ));
1050
1051        Ok(output)
1052    }
1053
1054    /// List all format versions currently loaded in memory.
1055    pub fn loaded_format_versions(&self) -> Vec<String> {
1056        self.bundles.lock().unwrap().keys().cloned().collect()
1057    }
1058
1059    /// List all variants available in a format version's bundle.
1060    ///
1061    /// Loads the bundle if not already loaded.
1062    pub fn variants(&self, fv: &str) -> Result<Vec<String>, MapperError> {
1063        self.ensure_bundle_loaded(fv)?;
1064        let bundles = self.bundles.lock().unwrap();
1065        let bundle = bundles.get(fv).unwrap();
1066        Ok(bundle.variants.keys().cloned().collect())
1067    }
1068}
1069
1070/// Metadata about a message type needed for constructing UNH segments.
1071#[derive(Debug, Clone)]
1072pub struct MessageMetadata {
1073    /// EDIFACT message type (e.g., `"UTILMD"`, `"MSCONS"`).
1074    pub message_type: String,
1075    /// UN/EDIFACT directory release code (e.g., `"11A"`, `"04B"`).
1076    pub release: String,
1077    /// Association-assigned code / MIG version (e.g., `"S2.1"`, `"2.4c"`).
1078    pub association_code: String,
1079}
1080
1081/// Envelope parameters for [`Mapper::to_edifact_interchange`].
1082#[derive(Debug, Clone)]
1083pub struct InterchangeEnvelope {
1084    /// Sender party (UNB S002).
1085    pub sender: EdifactParty,
1086    /// Receiver party (UNB S003).
1087    pub receiver: EdifactParty,
1088    /// Unique interchange reference (UNB 0020 / UNZ 0020).
1089    pub interchange_ref: String,
1090}
1091
1092/// An EDIFACT interchange party (sender or receiver) with codelist qualifier.
1093#[derive(Debug, Clone)]
1094pub struct EdifactParty {
1095    /// Party identification (e.g., MP-ID `"9900000000003"` or GLN `"4045458000000"`).
1096    pub id: String,
1097    /// Codelist qualifier: `"500"` = BDEW, `"14"` = GS1/EAN.
1098    pub qualifier: String,
1099}
1100
1101impl EdifactParty {
1102    /// Create a party with BDEW codelist qualifier (500).
1103    pub fn bdew(id: &str) -> Self {
1104        Self {
1105            id: id.to_string(),
1106            qualifier: "500".to_string(),
1107        }
1108    }
1109
1110    /// Create a party with GS1/EAN codelist qualifier (14).
1111    pub fn gs1(id: &str) -> Self {
1112        Self {
1113            id: id.to_string(),
1114            qualifier: "14".to_string(),
1115        }
1116    }
1117}
1118
1119/// A single message to include in an interchange built by
1120/// [`Mapper::to_edifact_interchange`].
1121#[derive(Debug, Clone)]
1122pub struct InterchangeMessage {
1123    /// Unique message reference number (used in UNH/UNT).
1124    pub message_ref: String,
1125    /// Message-level stammdaten (e.g., marktteilnehmer).
1126    pub msg_stammdaten: serde_json::Value,
1127    /// Transaction-level stammdaten (one per transaction).
1128    pub tx_stammdaten: Vec<serde_json::Value>,
1129    /// Format version (e.g., `"FV2604"`).
1130    pub fv: String,
1131    /// Message variant (e.g., `"UTILMD_Strom"`).
1132    pub variant: String,
1133    /// Pruefidentifikator (e.g., `"55001"`).
1134    pub pid: String,
1135}
1136
1137/// UN/EDIFACT directory release code for a message type.
1138///
1139/// These are stable per-message-type constants from the BDEW/DVGW specifications.
1140fn release_code_for_message_type(msg_type: &str) -> String {
1141    mig_bo4e::model::release_code_for_message_type(msg_type).to_string()
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use std::path::Path;
1148
1149    fn data_dir() -> Option<std::path::PathBuf> {
1150        // Try dist/ first (pre-built data bundles), then cache/mappings/
1151        let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist");
1152        if dist.join("edifact-data-FV2504.bin").exists() {
1153            return Some(dist);
1154        }
1155        let cache = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cache/mappings");
1156        if cache.join("FV2504").exists() {
1157            return Some(cache);
1158        }
1159        eprintln!("Skipping test: no DataBundle files found");
1160        None
1161    }
1162
1163    #[test]
1164    fn test_to_edifact_produces_edifact_output() {
1165        let Some(data_dir) = data_dir() else {
1166            return;
1167        };
1168        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1169
1170        let msg_stammdaten = serde_json::json!({
1171            "marktteilnehmer": [{
1172                "marktrolle": "MS",
1173                "rollencodenummer": "9900123456789",
1174                "codepflegeCode": "293"
1175            }]
1176        });
1177        let tx_stammdaten = serde_json::json!({
1178            "prozessdaten": {
1179                "pruefidentifikator": "55001",
1180                "vorgangId": "ABC123",
1181                "transaktionsgrund": "E01"
1182            }
1183        });
1184
1185        let result = mapper.to_edifact(
1186            &msg_stammdaten,
1187            &[tx_stammdaten],
1188            "FV2504",
1189            "UTILMD_Strom",
1190            "55001",
1191        );
1192        assert!(result.is_ok(), "to_edifact failed: {:?}", result.err());
1193        let edifact = result.unwrap();
1194        assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1195        // Should produce NAD segment from marktteilnehmer
1196        assert!(edifact.contains("NAD"), "Should contain NAD segment");
1197        // Should produce IDE segment from prozessdaten
1198        assert!(edifact.contains("IDE"), "Should contain IDE segment");
1199    }
1200
1201    #[test]
1202    fn test_to_edifact_struct_produces_edifact_output() {
1203        let Some(data_dir) = data_dir() else {
1204            return;
1205        };
1206        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1207
1208        let nachricht = serde_json::json!({
1209            "stammdaten": {
1210                "marktteilnehmer": [{
1211                    "marktrolle": "MS",
1212                    "rollencodenummer": "9900123456789",
1213                    "codepflegeCode": "293"
1214                }]
1215            },
1216            "transaktionen": [{
1217                "prozessdaten": {
1218                    "pruefidentifikator": "55001",
1219                    "vorgangId": "ABC123"
1220                }
1221            }]
1222        });
1223
1224        let result = mapper.to_edifact_struct(&nachricht, "FV2504", "UTILMD_Strom", "55001");
1225        assert!(
1226            result.is_ok(),
1227            "to_edifact_struct failed: {:?}",
1228            result.err()
1229        );
1230        let edifact = result.unwrap();
1231        assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1232    }
1233
1234    #[test]
1235    fn test_to_edifact_invalid_fv_returns_error() {
1236        let Some(data_dir) = data_dir() else {
1237            return;
1238        };
1239        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1240
1241        let result = mapper.to_edifact(
1242            &serde_json::json!({}),
1243            &[serde_json::json!({})],
1244            "FV9999",
1245            "UTILMD_Strom",
1246            "55001",
1247        );
1248        assert!(result.is_err());
1249    }
1250
1251    #[test]
1252    fn test_to_edifact_invalid_variant_returns_error() {
1253        let Some(data_dir) = data_dir() else {
1254            return;
1255        };
1256        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1257
1258        let result = mapper.to_edifact(
1259            &serde_json::json!({}),
1260            &[serde_json::json!({})],
1261            "FV2504",
1262            "NONEXISTENT",
1263            "55001",
1264        );
1265        assert!(result.is_err());
1266    }
1267
1268    #[test]
1269    fn test_to_edifact_invalid_pid_returns_error() {
1270        let Some(data_dir) = data_dir() else {
1271            return;
1272        };
1273        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1274
1275        let result = mapper.to_edifact(
1276            &serde_json::json!({}),
1277            &[serde_json::json!({})],
1278            "FV2504",
1279            "UTILMD_Strom",
1280            "99999",
1281        );
1282        assert!(result.is_err());
1283    }
1284
1285    #[test]
1286    fn test_association_code() {
1287        let Some(data_dir) = data_dir() else {
1288            return;
1289        };
1290        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1291
1292        let code = mapper.association_code("FV2504", "UTILMD_Strom").unwrap();
1293        assert_eq!(code, "S2.1");
1294
1295        let code = mapper.association_code("FV2504", "MSCONS").unwrap();
1296        assert_eq!(code, "2.4c");
1297    }
1298
1299    #[test]
1300    fn test_message_metadata() {
1301        let Some(data_dir) = data_dir() else {
1302            return;
1303        };
1304        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1305
1306        let meta = mapper.message_metadata("FV2504", "UTILMD_Strom").unwrap();
1307        assert_eq!(meta.message_type, "UTILMD");
1308        assert_eq!(meta.release, "11A");
1309        assert_eq!(meta.association_code, "S2.1");
1310    }
1311
1312    #[test]
1313    fn test_to_edifact_interchange() {
1314        let Some(data_dir) = data_dir() else {
1315            return;
1316        };
1317        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1318
1319        let result = mapper.to_edifact_interchange(
1320            &InterchangeEnvelope {
1321                sender: EdifactParty::bdew("9900000000003"),
1322                receiver: EdifactParty::bdew("9900000000001"),
1323                interchange_ref: "REF001".to_string(),
1324            },
1325            &[InterchangeMessage {
1326                message_ref: "MSG001".to_string(),
1327                msg_stammdaten: serde_json::json!({
1328                    "marktteilnehmer": [{
1329                        "marktrolle": "MS",
1330                        "rollencodenummer": "9900123456789",
1331                        "codepflegeCode": "293"
1332                    }]
1333                }),
1334                tx_stammdaten: vec![serde_json::json!({
1335                    "prozessdaten": {
1336                        "pruefidentifikator": "55001",
1337                        "vorgangId": "ABC123",
1338                        "transaktionsgrund": "E01"
1339                    }
1340                })],
1341                fv: "FV2504".to_string(),
1342                variant: "UTILMD_Strom".to_string(),
1343                pid: "55001".to_string(),
1344            }],
1345        );
1346        assert!(
1347            result.is_ok(),
1348            "to_edifact_interchange failed: {:?}",
1349            result.err()
1350        );
1351        let edifact = result.unwrap();
1352
1353        // Verify envelope structure
1354        assert!(edifact.starts_with("UNA:+.? '"), "Should start with UNA");
1355        assert!(
1356            edifact.contains("UNB+UNOC:3+9900000000003:500+9900000000001:500+"),
1357            "Should contain UNB with sender/receiver"
1358        );
1359        assert!(
1360            edifact.contains("UNH+MSG001+UTILMD:D:11A:UN:S2.1'"),
1361            "Should contain UNH with correct S009"
1362        );
1363        assert!(edifact.contains("NAD"), "Should contain body NAD segment");
1364        assert!(edifact.contains("UNT+"), "Should contain UNT");
1365        assert!(
1366            edifact.contains("+MSG001'"),
1367            "UNT should reference message ref"
1368        );
1369        assert!(
1370            edifact.contains("UNZ+1+REF001'"),
1371            "Should contain UNZ with count and ref"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_detect_pid_from_rff_z13() {
1377        let Some(data_dir) = data_dir() else {
1378            return;
1379        };
1380        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1381
1382        let edifact = "\
1383            UNB+UNOC:3+9978842000002:500+9900269000000:500+250331:1329+REF001'\
1384            UNH+MSG001+UTILMD:D:11A:UN:S2.1'\
1385            BGM+E01+DOC001'\
1386            DTM+137:202503311329?+00:303'\
1387            NAD+MS+9978842000002::293'\
1388            NAD+MR+9900269000000::293'\
1389            IDE+24+TX001'\
1390            DTM+92:202505312200?+00:303'\
1391            DTM+93:202512312300?+00:303'\
1392            STS+7++E01+ZW4+E03'\
1393            LOC+Z16+12345678900'\
1394            RFF+Z13:55001'\
1395            UNT+12+MSG001'\
1396            UNZ+1+REF001'";
1397
1398        let pid = mapper.detect_pid(edifact).unwrap();
1399        assert_eq!(pid, "55001");
1400    }
1401
1402    #[test]
1403    fn test_detect_pid_no_messages_returns_error() {
1404        let Some(data_dir) = data_dir() else {
1405            return;
1406        };
1407        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1408
1409        let edifact = "UNB+UNOC:3+SENDER:500+RECEIVER:500+250401:1200+REF'\
1410                        UNZ+0+REF'";
1411        assert!(mapper.detect_pid(edifact).is_err());
1412    }
1413
1414    #[test]
1415    fn test_list_pids_returns_entries() {
1416        let Some(data_dir) = data_dir() else {
1417            return;
1418        };
1419        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir)).unwrap();
1420        let pids = mapper.list_pids().expect("list_pids should succeed");
1421        assert!(!pids.is_empty(), "should return at least one PID");
1422        assert!(
1423            pids.iter().any(|p| p.pid == "55001"),
1424            "should include PID 55001"
1425        );
1426        assert!(
1427            pids.iter().any(|p| p.fv == "FV2504"),
1428            "should include FV2504"
1429        );
1430        assert!(
1431            pids.iter().any(|p| p.variant == "UTILMD_Strom"),
1432            "should include UTILMD_Strom"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_pid_requirements_returns_requirements() {
1438        let Some(data_dir) = data_dir() else {
1439            return;
1440        };
1441        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1442
1443        let req = mapper
1444            .pid_requirements("FV2504", "UTILMD_Strom", "55001")
1445            .expect("pid_requirements should succeed");
1446
1447        assert_eq!(req.pid, "55001");
1448        assert!(
1449            !req.entities.is_empty(),
1450            "55001 should have at least one entity"
1451        );
1452        assert!(
1453            req.entities.iter().any(|e| e.entity == "Prozessdaten"),
1454            "55001 should have a Prozessdaten entity"
1455        );
1456    }
1457}