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