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