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