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
233                                .strip_prefix("pid_")
234                                .unwrap_or(pid_key)
235                                .to_string();
236                            result.push(PidListEntry {
237                                fv: fv.clone(),
238                                variant: variant.clone(),
239                                pid,
240                                beschreibung: req.beschreibung.clone(),
241                            });
242                        }
243                    }
244                }
245            }
246        }
247
248        result.sort_by(|a, b| a.pid.cmp(&b.pid));
249        Ok(result)
250    }
251
252    /// Validate a BO4E JSON object against PID requirements.
253    ///
254    /// Returns a list of validation errors. Empty list = valid.
255    /// The `json` should be the transaction-level stammdaten (the entity map).
256    pub fn validate_pid(
257        &self,
258        json: &serde_json::Value,
259        fv: &str,
260        variant: &str,
261        pid: &str,
262    ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
263        self.ensure_bundle_loaded(fv)?;
264        let bundles = self.bundles.lock().unwrap();
265        let bundle = bundles.get(fv).unwrap();
266        let vc = bundle
267            .variant(variant)
268            .ok_or_else(|| MapperError::VariantNotFound {
269                fv: fv.to_string(),
270                variant: variant.to_string(),
271            })?;
272        let pid_key = format!("pid_{pid}");
273        let requirements =
274            vc.pid_requirements
275                .get(&pid_key)
276                .ok_or_else(|| MapperError::PidNotFound {
277                    fv: fv.to_string(),
278                    variant: variant.to_string(),
279                    pid: pid.to_string(),
280                })?;
281
282        Ok(mig_bo4e::pid_validation::validate_pid_json(
283            json,
284            requirements,
285        ))
286    }
287
288    /// Validate a typed BO4E struct against PID requirements.
289    ///
290    /// Convenience wrapper that serializes the struct to JSON first.
291    /// Works with any `Pid*Interchange` or `Pid*MessageStammdaten` type.
292    ///
293    /// # Example
294    /// ```ignore
295    /// let interchange = build_55001_interchange();
296    /// let errors = mapper.validate_pid_struct(&interchange, "FV2504", "UTILMD_Strom", "55001")?;
297    /// assert!(errors.is_empty(), "Errors:\n{}", ValidationReport(errors));
298    /// ```
299    pub fn validate_pid_struct(
300        &self,
301        value: &impl serde::Serialize,
302        fv: &str,
303        variant: &str,
304        pid: &str,
305    ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
306        let json = serde_json::to_value(value).map_err(|e| {
307            MapperError::Mapping(mig_bo4e::MappingError::TypeConversion(e.to_string()))
308        })?;
309        self.validate_pid(&json, fv, variant, pid)
310    }
311
312    /// Validate with AHB condition awareness.
313    ///
314    /// Reverse-maps the JSON to EDIFACT segments, evaluates AHB conditions,
315    /// and reports fields as required/optional based on the actual data present.
316    ///
317    /// Falls back to basic validation (without conditions) if no condition
318    /// evaluator is available for the given variant/format version combination.
319    pub fn validate_pid_with_conditions(
320        &self,
321        json: &serde_json::Value,
322        fv: &str,
323        variant: &str,
324        pid: &str,
325    ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
326        self.ensure_bundle_loaded(fv)?;
327        let bundles = self.bundles.lock().unwrap();
328        let bundle = bundles.get(fv).unwrap();
329        let vc = bundle
330            .variant(variant)
331            .ok_or_else(|| MapperError::VariantNotFound {
332                fv: fv.to_string(),
333                variant: variant.to_string(),
334            })?;
335        let pid_key = format!("pid_{pid}");
336
337        let requirements =
338            vc.pid_requirements
339                .get(&pid_key)
340                .ok_or_else(|| MapperError::PidNotFound {
341                    fv: fv.to_string(),
342                    variant: variant.to_string(),
343                    pid: pid.to_string(),
344                })?;
345
346        // Try to get a condition evaluator for this variant
347        let evaluator = crate::evaluator_factory::create_evaluator(variant, fv);
348
349        if let Some(evaluator) = evaluator {
350            // Reverse-map JSON to EDIFACT segments for condition evaluation context
351            let defs = vc
352                .combined_defs
353                .get(&pid_key)
354                .ok_or_else(|| MapperError::PidNotFound {
355                    fv: fv.to_string(),
356                    variant: variant.to_string(),
357                    pid: pid.to_string(),
358                })?;
359            let engine = MappingEngine::from_definitions(defs.clone());
360            let tree = engine.map_all_reverse(json, None);
361
362            // Convert AssembledTree to flat OwnedSegments for EvaluationContext
363            let segments = crate::tree_to_segments::tree_to_owned_segments(&tree);
364
365            // Validate with condition awareness
366            Ok(crate::evaluator_factory::validate_with_boxed_evaluator(
367                evaluator.as_ref(),
368                json,
369                requirements,
370                pid,
371                &segments,
372            ))
373        } else {
374            // No evaluator available — fall back to basic validation
375            Ok(mig_bo4e::pid_validation::validate_pid_json_transaction(
376                json,
377                requirements,
378            ))
379        }
380    }
381
382    /// Convert BO4E JSON back to an EDIFACT string.
383    ///
384    /// Takes message-level stammdaten, a slice of per-transaction stammdaten,
385    /// and produces an EDIFACT message body (UNH through UNT content segments,
386    /// without UNB/UNZ interchange envelope).
387    ///
388    /// # Arguments
389    ///
390    /// * `msg_stammdaten` — message-level entities (e.g., Marktteilnehmer from SG2)
391    /// * `tx_stammdaten` — per-transaction entities (one per transaction/SG4 instance)
392    /// * `fv` — format version (e.g., "FV2504")
393    /// * `variant` — message variant (e.g., "UTILMD_Strom")
394    /// * `pid` — Pruefidentifikator (e.g., "55001")
395    ///
396    /// # Example
397    ///
398    /// ```ignore
399    /// let edifact = mapper.to_edifact(
400    ///     &msg_json,
401    ///     &[tx_json],
402    ///     "FV2504",
403    ///     "UTILMD_Strom",
404    ///     "55001",
405    /// )?;
406    /// ```
407    pub fn to_edifact(
408        &self,
409        msg_stammdaten: &serde_json::Value,
410        tx_stammdaten: &[serde_json::Value],
411        fv: &str,
412        variant: &str,
413        pid: &str,
414    ) -> Result<String, MapperError> {
415        self.ensure_bundle_loaded(fv)?;
416        let bundles = self.bundles.lock().unwrap();
417        let bundle = bundles.get(fv).unwrap();
418        let vc = bundle
419            .variant(variant)
420            .ok_or_else(|| MapperError::VariantNotFound {
421                fv: fv.to_string(),
422                variant: variant.to_string(),
423            })?;
424
425        let tx_group = vc
426            .tx_group(pid)
427            .ok_or_else(|| MapperError::PidNotFound {
428                fv: fv.to_string(),
429                variant: variant.to_string(),
430                pid: pid.to_string(),
431            })?;
432
433        let msg_engine = vc.msg_engine(pid);
434        let tx_engine =
435            vc.tx_engine(pid)
436                .ok_or_else(|| MapperError::PidNotFound {
437                    fv: fv.to_string(),
438                    variant: variant.to_string(),
439                    pid: pid.to_string(),
440                })?;
441
442        let filtered_mig =
443            vc.filtered_mig(pid)
444                .ok_or_else(|| MapperError::NoMigSchema {
445                    fv: fv.to_string(),
446                    variant: variant.to_string(),
447                })?;
448
449        // Build MappedMessage from the provided JSON
450        let transaktionen: Vec<mig_bo4e::model::MappedTransaktion> = tx_stammdaten
451            .iter()
452            .map(|tx| mig_bo4e::model::MappedTransaktion {
453                stammdaten: tx.clone(),
454                nesting_info: Default::default(),
455                dp_routing: Default::default(),
456            })
457            .collect();
458        let mapped = mig_bo4e::model::MappedMessage {
459            stammdaten: msg_stammdaten.clone(),
460            transaktionen,
461            nesting_info: Default::default(),
462            dp_routing: Default::default(),
463            inter_group_segments: Default::default(),
464        };
465
466        // Reverse map → AssembledTree
467        let tree = MappingEngine::map_interchange_reverse(
468            &msg_engine,
469            &tx_engine,
470            &mapped,
471            tx_group,
472            Some(&filtered_mig),
473        );
474
475        // Disassemble → ordered segments
476        let disassembler =
477            mig_assembly::disassembler::Disassembler::new(&filtered_mig);
478        let segments = disassembler.disassemble(&tree);
479
480        // Render to EDIFACT string with default delimiters
481        let delimiters = edifact_primitives::EdifactDelimiters::default();
482        Ok(mig_assembly::renderer::render_edifact(
483            &segments,
484            &delimiters,
485        ))
486    }
487
488    /// Convert a typed BO4E struct to an EDIFACT string.
489    ///
490    /// Convenience wrapper that serializes the struct to JSON first.
491    /// The struct should serialize to the `Nachricht` shape:
492    /// `{ "stammdaten": {...}, "transaktionen": [{...}] }`
493    pub fn to_edifact_struct(
494        &self,
495        nachricht: &impl serde::Serialize,
496        fv: &str,
497        variant: &str,
498        pid: &str,
499    ) -> Result<String, MapperError> {
500        let json = serde_json::to_value(nachricht)
501            .map_err(|e| MapperError::Serialization(e.to_string()))?;
502
503        let msg_stammdaten = json
504            .get("stammdaten")
505            .cloned()
506            .unwrap_or(serde_json::Value::Object(Default::default()));
507
508        let tx_stammdaten: Vec<serde_json::Value> = json
509            .get("transaktionen")
510            .and_then(|v| v.as_array())
511            .cloned()
512            .unwrap_or_default();
513
514        self.to_edifact(&msg_stammdaten, &tx_stammdaten, fv, variant, pid)
515    }
516
517    /// Parse an EDIFACT interchange string into a typed PID interchange struct.
518    ///
519    /// Runs the full pipeline: tokenize → split messages → assemble → forward-map → deserialize.
520    /// The type parameters `M` and `T` are the message-level and transaction-level
521    /// stammdaten types from the generated PID module.
522    ///
523    /// # Example
524    ///
525    /// ```ignore
526    /// use bo4e_edifact_types::generated::fv2504::utilmd::pids::pid_55001::*;
527    ///
528    /// let interchange: Interchange<Pid55001MsgStammdaten, Pid55001TxStammdaten> =
529    ///     mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", "55001")?;
530    ///
531    /// let tx = &interchange.nachrichten[0].transaktionen[0];
532    /// println!("Vorgang: {}", tx.prozessdaten.vorgang_id);
533    /// ```
534    pub fn from_edifact<M, T>(
535        &self,
536        edifact: &str,
537        fv: &str,
538        variant: &str,
539        pid: &str,
540    ) -> Result<mig_bo4e::model::Interchange<M, T>, MapperError>
541    where
542        M: serde::de::DeserializeOwned,
543        T: serde::de::DeserializeOwned,
544    {
545        self.ensure_bundle_loaded(fv)?;
546        let bundles = self.bundles.lock().unwrap();
547        let bundle = bundles.get(fv).unwrap();
548        let vc = bundle
549            .variant(variant)
550            .ok_or_else(|| MapperError::VariantNotFound {
551                fv: fv.to_string(),
552                variant: variant.to_string(),
553            })?;
554
555        let tx_group = vc
556            .tx_group(pid)
557            .ok_or_else(|| MapperError::PidNotFound {
558                fv: fv.to_string(),
559                variant: variant.to_string(),
560                pid: pid.to_string(),
561            })?;
562
563        let msg_engine = vc.msg_engine(pid);
564        let tx_engine =
565            vc.tx_engine(pid)
566                .ok_or_else(|| MapperError::PidNotFound {
567                    fv: fv.to_string(),
568                    variant: variant.to_string(),
569                    pid: pid.to_string(),
570                })?;
571
572        let filtered_mig =
573            vc.filtered_mig(pid)
574                .ok_or_else(|| MapperError::NoMigSchema {
575                    fv: fv.to_string(),
576                    variant: variant.to_string(),
577                })?;
578
579        // Tokenize → split → assemble
580        let svc = ConversionService::from_mig(filtered_mig);
581        let (chunks, trees) = svc.convert_interchange_to_trees(edifact)?;
582
583        let tree = trees
584            .first()
585            .ok_or_else(|| MapperError::Assembly(
586                mig_assembly::AssemblyError::ParseError("No messages in interchange".to_string()),
587            ))?;
588
589        // Extract envelope metadata
590        let interchangedaten =
591            mig_bo4e::model::extract_interchangedaten(&chunks.envelope);
592        let msg_chunk = chunks.messages.first().ok_or_else(|| {
593            MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
594                "No message chunks".to_string(),
595            ))
596        })?;
597        let (unh_ref, nachrichten_typ) =
598            mig_bo4e::model::extract_unh_fields(&msg_chunk.unh);
599        let nachrichtendaten = mig_bo4e::model::Nachrichtendaten {
600            unh_referenz: unh_ref,
601            nachrichten_typ,
602        };
603
604        // Forward-map to typed interchange
605        MappingEngine::map_interchange_typed::<M, T>(
606            &msg_engine,
607            &tx_engine,
608            tree,
609            tx_group,
610            true,
611            nachrichtendaten,
612            interchangedaten,
613        )
614        .map_err(|e| MapperError::Serialization(e.to_string()))
615    }
616
617    /// Detect the PID (Pruefidentifikator) from a raw EDIFACT interchange.
618    ///
619    /// Tokenizes the input, splits into messages, and extracts the PID from the
620    /// first message using the RFF+Z13 segment (primary) or BGM+STS fallback.
621    ///
622    /// This enables inbound message processing where the PID is not known upfront:
623    ///
624    /// ```ignore
625    /// let pid = mapper.detect_pid(edifact_str)?;
626    /// let interchange: MyType = mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", &pid)?;
627    /// ```
628    pub fn detect_pid(&self, edifact: &str) -> Result<String, MapperError> {
629        let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
630        let chunks = mig_assembly::split_messages(segments)?;
631        let msg_chunk =
632            chunks
633                .messages
634                .first()
635                .ok_or_else(|| MapperError::Assembly(
636                    mig_assembly::AssemblyError::ParseError(
637                        "No messages found in EDIFACT content".to_string(),
638                    ),
639                ))?;
640        let msg_segments = msg_chunk.message_segments();
641        mig_assembly::pid_detect::detect_pid(&msg_segments).map_err(MapperError::Assembly)
642    }
643
644    /// Get the UNH association code for a variant (e.g., `"S2.1"`, `"2.4c"`).
645    ///
646    /// This is the version string from the MIG schema, used as the last component
647    /// of the UNH S009 composite: `UTILMD:D:11A:UN:S2.1`.
648    ///
649    /// # Example
650    /// ```ignore
651    /// let code = mapper.association_code("FV2604", "UTILMD_Strom")?;
652    /// assert_eq!(code, "S2.1");
653    /// ```
654    pub fn association_code(&self, fv: &str, variant: &str) -> Result<String, MapperError> {
655        let meta = self.message_metadata(fv, variant)?;
656        Ok(meta.association_code)
657    }
658
659    /// Get full message metadata for a variant, including the UNH S009 components.
660    ///
661    /// Returns the message type, UN/EDIFACT release code, and association code
662    /// needed to construct UNH segments.
663    pub fn message_metadata(
664        &self,
665        fv: &str,
666        variant: &str,
667    ) -> Result<MessageMetadata, MapperError> {
668        self.ensure_bundle_loaded(fv)?;
669        let bundles = self.bundles.lock().unwrap();
670        let bundle = bundles.get(fv).unwrap();
671        let vc = bundle
672            .variant(variant)
673            .ok_or_else(|| MapperError::VariantNotFound {
674                fv: fv.to_string(),
675                variant: variant.to_string(),
676            })?;
677        let mig = vc
678            .mig_schema
679            .as_ref()
680            .ok_or_else(|| MapperError::NoMigSchema {
681                fv: fv.to_string(),
682                variant: variant.to_string(),
683            })?;
684        Ok(MessageMetadata {
685            message_type: mig.message_type.clone(),
686            release: release_code_for_message_type(&mig.message_type),
687            association_code: mig.version.clone(),
688        })
689    }
690
691    /// Convert BO4E JSON to a complete EDIFACT interchange with envelope segments.
692    ///
693    /// Produces a full interchange including UNA, UNB, UNH, message body, UNT, and UNZ.
694    ///
695    /// # Example
696    /// ```ignore
697    /// let edifact = mapper.to_edifact_interchange(
698    ///     &InterchangeEnvelope {
699    ///         sender: EdifactParty::bdew("9900000000003"),
700    ///         receiver: EdifactParty::bdew("9900000000001"),
701    ///         interchange_ref: "REF001".to_string(),
702    ///     },
703    ///     &[InterchangeMessage {
704    ///         message_ref: "MSG001".to_string(),
705    ///         msg_stammdaten: serde_json::json!({"marktteilnehmer": []}),
706    ///         tx_stammdaten: vec![serde_json::json!({"prozessdaten": {"pruefidentifikator": "55001"}})],
707    ///         fv: "FV2604".to_string(),
708    ///         variant: "UTILMD_Strom".to_string(),
709    ///         pid: "55001".to_string(),
710    ///     }],
711    /// )?;
712    /// assert!(edifact.starts_with("UNA:+.? '"));
713    /// ```
714    pub fn to_edifact_interchange(
715        &self,
716        envelope: &InterchangeEnvelope,
717        messages: &[InterchangeMessage],
718    ) -> Result<String, MapperError> {
719        let delimiters = edifact_primitives::EdifactDelimiters::default();
720        let sep = delimiters.component as char;
721        let elem = delimiters.element as char;
722        let seg_term = delimiters.segment as char;
723
724        let mut output = String::new();
725
726        // UNA — Service string advice
727        output.push_str(&format!(
728            "UNA{}{}{}{}{}{}",
729            sep,                            // component separator
730            elem,                           // element separator
731            delimiters.decimal as char,     // decimal notation
732            delimiters.release as char,     // release/escape character
733            ' ',                            // reserved (space)
734            seg_term,                       // segment terminator
735        ));
736
737        // UNB — Interchange header
738        let now = chrono::Utc::now();
739        let date_str = now.format("%y%m%d").to_string();
740        let time_str = now.format("%H%M").to_string();
741        let sender = &envelope.sender;
742        let receiver = &envelope.receiver;
743        let interchange_ref = &envelope.interchange_ref;
744        output.push_str(&format!(
745            "UNB{elem}UNOC{sep}3{elem}{sid}{sep}{sq}{elem}{rid}{sep}{rq}{elem}{date_str}{sep}{time_str}{elem}{interchange_ref}{seg_term}",
746            sid = sender.id,
747            sq = sender.qualifier,
748            rid = receiver.id,
749            rq = receiver.qualifier,
750        ));
751
752        let mut message_count = 0u32;
753
754        for msg in messages {
755            let meta = self.message_metadata(&msg.fv, &msg.variant)?;
756
757            // Generate body segments
758            let body = self.to_edifact(
759                &msg.msg_stammdaten,
760                &msg.tx_stammdaten,
761                &msg.fv,
762                &msg.variant,
763                &msg.pid,
764            )?;
765
766            // Count segments in body (split by segment terminator, filter empty)
767            let body_seg_count = body
768                .split(seg_term)
769                .filter(|s: &&str| !s.is_empty())
770                .count();
771            // UNH + body segments + UNT = total segment count
772            let segment_count = body_seg_count + 2;
773
774            // UNH — Message header
775            output.push_str(&format!(
776                "UNH{elem}{ref}{elem}{msg_type}{sep}D{sep}{release}{sep}UN{sep}{assoc}{seg_term}",
777                ref = msg.message_ref,
778                msg_type = meta.message_type,
779                release = meta.release,
780                assoc = meta.association_code,
781            ));
782
783            // Body segments
784            output.push_str(&body);
785
786            // UNT — Message trailer
787            output.push_str(&format!(
788                "UNT{elem}{segment_count}{elem}{ref}{seg_term}",
789                ref = msg.message_ref,
790            ));
791
792            message_count += 1;
793        }
794
795        // UNZ — Interchange trailer
796        output.push_str(&format!(
797            "UNZ{elem}{message_count}{elem}{interchange_ref}{seg_term}",
798        ));
799
800        Ok(output)
801    }
802
803    /// List all format versions currently loaded in memory.
804    pub fn loaded_format_versions(&self) -> Vec<String> {
805        self.bundles.lock().unwrap().keys().cloned().collect()
806    }
807
808    /// List all variants available in a format version's bundle.
809    ///
810    /// Loads the bundle if not already loaded.
811    pub fn variants(&self, fv: &str) -> Result<Vec<String>, MapperError> {
812        self.ensure_bundle_loaded(fv)?;
813        let bundles = self.bundles.lock().unwrap();
814        let bundle = bundles.get(fv).unwrap();
815        Ok(bundle.variants.keys().cloned().collect())
816    }
817}
818
819/// Metadata about a message type needed for constructing UNH segments.
820#[derive(Debug, Clone)]
821pub struct MessageMetadata {
822    /// EDIFACT message type (e.g., `"UTILMD"`, `"MSCONS"`).
823    pub message_type: String,
824    /// UN/EDIFACT directory release code (e.g., `"11A"`, `"04B"`).
825    pub release: String,
826    /// Association-assigned code / MIG version (e.g., `"S2.1"`, `"2.4c"`).
827    pub association_code: String,
828}
829
830/// Envelope parameters for [`Mapper::to_edifact_interchange`].
831#[derive(Debug, Clone)]
832pub struct InterchangeEnvelope {
833    /// Sender party (UNB S002).
834    pub sender: EdifactParty,
835    /// Receiver party (UNB S003).
836    pub receiver: EdifactParty,
837    /// Unique interchange reference (UNB 0020 / UNZ 0020).
838    pub interchange_ref: String,
839}
840
841/// An EDIFACT interchange party (sender or receiver) with codelist qualifier.
842#[derive(Debug, Clone)]
843pub struct EdifactParty {
844    /// Party identification (e.g., MP-ID `"9900000000003"` or GLN `"4045458000000"`).
845    pub id: String,
846    /// Codelist qualifier: `"500"` = BDEW, `"14"` = GS1/EAN.
847    pub qualifier: String,
848}
849
850impl EdifactParty {
851    /// Create a party with BDEW codelist qualifier (500).
852    pub fn bdew(id: &str) -> Self {
853        Self {
854            id: id.to_string(),
855            qualifier: "500".to_string(),
856        }
857    }
858
859    /// Create a party with GS1/EAN codelist qualifier (14).
860    pub fn gs1(id: &str) -> Self {
861        Self {
862            id: id.to_string(),
863            qualifier: "14".to_string(),
864        }
865    }
866}
867
868/// A single message to include in an interchange built by
869/// [`Mapper::to_edifact_interchange`].
870#[derive(Debug, Clone)]
871pub struct InterchangeMessage {
872    /// Unique message reference number (used in UNH/UNT).
873    pub message_ref: String,
874    /// Message-level stammdaten (e.g., marktteilnehmer).
875    pub msg_stammdaten: serde_json::Value,
876    /// Transaction-level stammdaten (one per transaction).
877    pub tx_stammdaten: Vec<serde_json::Value>,
878    /// Format version (e.g., `"FV2604"`).
879    pub fv: String,
880    /// Message variant (e.g., `"UTILMD_Strom"`).
881    pub variant: String,
882    /// Pruefidentifikator (e.g., `"55001"`).
883    pub pid: String,
884}
885
886/// UN/EDIFACT directory release code for a message type.
887///
888/// These are stable per-message-type constants from the BDEW/DVGW specifications.
889fn release_code_for_message_type(msg_type: &str) -> String {
890    match msg_type {
891        "APERAK" => "07B",
892        "COMDIS" => "17A",
893        "CONTRL" => "04B",
894        "IFTSTA" => "18A",
895        "INSRPT" => "18A",
896        "INVOIC" => "06A",
897        "MSCONS" => "04B",
898        "ORDCHG" => "09B",
899        "ORDERS" => "09B",
900        "ORDRSP" => "10A",
901        "PARTIN" => "20B",
902        "PRICAT" => "20B",
903        "QUOTES" => "10A",
904        "REMADV" => "05A",
905        "REQOTE" => "10A",
906        "UTILMD" => "11A",
907        "UTILTS" => "18A",
908        _ => "04B", // fallback
909    }
910    .to_string()
911}
912
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use std::path::Path;
918
919    fn data_dir() -> Option<std::path::PathBuf> {
920        // Try dist/ first (pre-built data bundles), then cache/mappings/
921        let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist");
922        if dist.join("edifact-data-FV2504.bin").exists() {
923            return Some(dist);
924        }
925        let cache = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cache/mappings");
926        if cache.join("FV2504").exists() {
927            return Some(cache);
928        }
929        eprintln!("Skipping test: no DataBundle files found");
930        None
931    }
932
933    #[test]
934    fn test_to_edifact_produces_edifact_output() {
935        let Some(data_dir) = data_dir() else {
936            return;
937        };
938        let mapper =
939            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
940
941        let msg_stammdaten = serde_json::json!({
942            "marktteilnehmer": [{
943                "marktrolle": "MS",
944                "rollencodenummer": "9900123456789",
945                "codepflegeCode": "293"
946            }]
947        });
948        let tx_stammdaten = serde_json::json!({
949            "prozessdaten": {
950                "pruefidentifikator": "55001",
951                "vorgangId": "ABC123",
952                "transaktionsgrund": "E01"
953            }
954        });
955
956        let result = mapper.to_edifact(
957            &msg_stammdaten,
958            &[tx_stammdaten],
959            "FV2504",
960            "UTILMD_Strom",
961            "55001",
962        );
963        assert!(result.is_ok(), "to_edifact failed: {:?}", result.err());
964        let edifact = result.unwrap();
965        assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
966        // Should produce NAD segment from marktteilnehmer
967        assert!(edifact.contains("NAD"), "Should contain NAD segment");
968        // Should produce IDE segment from prozessdaten
969        assert!(edifact.contains("IDE"), "Should contain IDE segment");
970    }
971
972    #[test]
973    fn test_to_edifact_struct_produces_edifact_output() {
974        let Some(data_dir) = data_dir() else {
975            return;
976        };
977        let mapper =
978            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
979
980        let nachricht = serde_json::json!({
981            "stammdaten": {
982                "marktteilnehmer": [{
983                    "marktrolle": "MS",
984                    "rollencodenummer": "9900123456789",
985                    "codepflegeCode": "293"
986                }]
987            },
988            "transaktionen": [{
989                "prozessdaten": {
990                    "pruefidentifikator": "55001",
991                    "vorgangId": "ABC123"
992                }
993            }]
994        });
995
996        let result = mapper.to_edifact_struct(&nachricht, "FV2504", "UTILMD_Strom", "55001");
997        assert!(
998            result.is_ok(),
999            "to_edifact_struct failed: {:?}",
1000            result.err()
1001        );
1002        let edifact = result.unwrap();
1003        assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1004    }
1005
1006    #[test]
1007    fn test_to_edifact_invalid_fv_returns_error() {
1008        let Some(data_dir) = data_dir() else {
1009            return;
1010        };
1011        let mapper =
1012            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1013
1014        let result = mapper.to_edifact(
1015            &serde_json::json!({}),
1016            &[serde_json::json!({})],
1017            "FV9999",
1018            "UTILMD_Strom",
1019            "55001",
1020        );
1021        assert!(result.is_err());
1022    }
1023
1024    #[test]
1025    fn test_to_edifact_invalid_variant_returns_error() {
1026        let Some(data_dir) = data_dir() else {
1027            return;
1028        };
1029        let mapper =
1030            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1031
1032        let result = mapper.to_edifact(
1033            &serde_json::json!({}),
1034            &[serde_json::json!({})],
1035            "FV2504",
1036            "NONEXISTENT",
1037            "55001",
1038        );
1039        assert!(result.is_err());
1040    }
1041
1042    #[test]
1043    fn test_to_edifact_invalid_pid_returns_error() {
1044        let Some(data_dir) = data_dir() else {
1045            return;
1046        };
1047        let mapper =
1048            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1049
1050        let result = mapper.to_edifact(
1051            &serde_json::json!({}),
1052            &[serde_json::json!({})],
1053            "FV2504",
1054            "UTILMD_Strom",
1055            "99999",
1056        );
1057        assert!(result.is_err());
1058    }
1059
1060    #[test]
1061    fn test_association_code() {
1062        let Some(data_dir) = data_dir() else {
1063            return;
1064        };
1065        let mapper =
1066            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1067
1068        let code = mapper.association_code("FV2504", "UTILMD_Strom").unwrap();
1069        assert_eq!(code, "S2.1");
1070
1071        let code = mapper.association_code("FV2504", "MSCONS").unwrap();
1072        assert_eq!(code, "2.4c");
1073    }
1074
1075    #[test]
1076    fn test_message_metadata() {
1077        let Some(data_dir) = data_dir() else {
1078            return;
1079        };
1080        let mapper =
1081            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1082
1083        let meta = mapper.message_metadata("FV2504", "UTILMD_Strom").unwrap();
1084        assert_eq!(meta.message_type, "UTILMD");
1085        assert_eq!(meta.release, "11A");
1086        assert_eq!(meta.association_code, "S2.1");
1087    }
1088
1089    #[test]
1090    fn test_to_edifact_interchange() {
1091        let Some(data_dir) = data_dir() else {
1092            return;
1093        };
1094        let mapper =
1095            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1096
1097        let result = mapper.to_edifact_interchange(
1098            &InterchangeEnvelope {
1099                sender: EdifactParty::bdew("9900000000003"),
1100                receiver: EdifactParty::bdew("9900000000001"),
1101                interchange_ref: "REF001".to_string(),
1102            },
1103            &[InterchangeMessage {
1104                message_ref: "MSG001".to_string(),
1105                msg_stammdaten: serde_json::json!({
1106                    "marktteilnehmer": [{
1107                        "marktrolle": "MS",
1108                        "rollencodenummer": "9900123456789",
1109                        "codepflegeCode": "293"
1110                    }]
1111                }),
1112                tx_stammdaten: vec![serde_json::json!({
1113                    "prozessdaten": {
1114                        "pruefidentifikator": "55001",
1115                        "vorgangId": "ABC123",
1116                        "transaktionsgrund": "E01"
1117                    }
1118                })],
1119                fv: "FV2504".to_string(),
1120                variant: "UTILMD_Strom".to_string(),
1121                pid: "55001".to_string(),
1122            }],
1123        );
1124        assert!(
1125            result.is_ok(),
1126            "to_edifact_interchange failed: {:?}",
1127            result.err()
1128        );
1129        let edifact = result.unwrap();
1130
1131        // Verify envelope structure
1132        assert!(edifact.starts_with("UNA:+.? '"), "Should start with UNA");
1133        assert!(edifact.contains("UNB+UNOC:3+9900000000003:500+9900000000001:500+"),
1134            "Should contain UNB with sender/receiver");
1135        assert!(edifact.contains("UNH+MSG001+UTILMD:D:11A:UN:S2.1'"),
1136            "Should contain UNH with correct S009");
1137        assert!(edifact.contains("NAD"), "Should contain body NAD segment");
1138        assert!(edifact.contains("UNT+"), "Should contain UNT");
1139        assert!(edifact.contains("+MSG001'"), "UNT should reference message ref");
1140        assert!(edifact.contains("UNZ+1+REF001'"), "Should contain UNZ with count and ref");
1141    }
1142
1143    #[test]
1144    fn test_detect_pid_from_rff_z13() {
1145        let Some(data_dir) = data_dir() else {
1146            return;
1147        };
1148        let mapper =
1149            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1150
1151        let edifact = "\
1152            UNB+UNOC:3+9978842000002:500+9900269000000:500+250331:1329+REF001'\
1153            UNH+MSG001+UTILMD:D:11A:UN:S2.1'\
1154            BGM+E01+DOC001'\
1155            DTM+137:202503311329?+00:303'\
1156            NAD+MS+9978842000002::293'\
1157            NAD+MR+9900269000000::293'\
1158            IDE+24+TX001'\
1159            DTM+92:202505312200?+00:303'\
1160            DTM+93:202512312300?+00:303'\
1161            STS+7++E01+ZW4+E03'\
1162            LOC+Z16+12345678900'\
1163            RFF+Z13:55001'\
1164            UNT+12+MSG001'\
1165            UNZ+1+REF001'";
1166
1167        let pid = mapper.detect_pid(edifact).unwrap();
1168        assert_eq!(pid, "55001");
1169    }
1170
1171    #[test]
1172    fn test_detect_pid_no_messages_returns_error() {
1173        let Some(data_dir) = data_dir() else {
1174            return;
1175        };
1176        let mapper =
1177            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1178
1179        let edifact = "UNB+UNOC:3+SENDER:500+RECEIVER:500+250401:1200+REF'\
1180                        UNZ+0+REF'";
1181        assert!(mapper.detect_pid(edifact).is_err());
1182    }
1183
1184    #[test]
1185    fn test_list_pids_returns_entries() {
1186        let Some(data_dir) = data_dir() else {
1187            return;
1188        };
1189        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir)).unwrap();
1190        let pids = mapper.list_pids().expect("list_pids should succeed");
1191        assert!(!pids.is_empty(), "should return at least one PID");
1192        assert!(
1193            pids.iter().any(|p| p.pid == "55001"),
1194            "should include PID 55001"
1195        );
1196        assert!(
1197            pids.iter().any(|p| p.fv == "FV2504"),
1198            "should include FV2504"
1199        );
1200        assert!(
1201            pids.iter().any(|p| p.variant == "UTILMD_Strom"),
1202            "should include UTILMD_Strom"
1203        );
1204    }
1205
1206    #[test]
1207    fn test_pid_requirements_returns_requirements() {
1208        let Some(data_dir) = data_dir() else {
1209            return;
1210        };
1211        let mapper =
1212            Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1213
1214        let req = mapper
1215            .pid_requirements("FV2504", "UTILMD_Strom", "55001")
1216            .expect("pid_requirements should succeed");
1217
1218        assert_eq!(req.pid, "55001");
1219        assert!(
1220            !req.entities.is_empty(),
1221            "55001 should have at least one entity"
1222        );
1223        assert!(
1224            req.entities.iter().any(|e| e.entity == "Prozessdaten"),
1225            "55001 should have a Prozessdaten entity"
1226        );
1227    }
1228}