Skip to main content

edifact_mapper/
mapper.rs

1//! High-level [`Mapper`] API for EDIFACT-to-BO4E conversion.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5
6use mig_assembly::ConversionService;
7use mig_bo4e::engine::DataBundle;
8use mig_bo4e::MappingEngine;
9
10use crate::data_dir::DataDir;
11use crate::error::MapperError;
12
13/// Result of a BO4E mapping operation.
14pub struct Bo4eResult {
15    /// The PID (Pruefidentifikator) that was detected or specified.
16    pub pid: String,
17    /// The EDIFACT message type (e.g., "UTILMD", "MSCONS").
18    pub message_type: String,
19    /// The message variant (e.g., "UTILMD_Strom", "MSCONS").
20    pub variant: String,
21    /// The mapped BO4E JSON output.
22    pub bo4e: serde_json::Value,
23}
24
25/// High-level facade for bidirectional EDIFACT ↔ BO4E conversion.
26///
27/// Wraps [`DataBundle`] loading with lazy/eager initialization, and provides
28/// convenient accessors for [`ConversionService`] and [`MappingEngine`] instances.
29///
30/// # Inbound (EDIFACT → BO4E)
31///
32/// ```ignore
33/// use edifact_mapper::{DataDir, Mapper};
34///
35/// let mapper = Mapper::from_data_dir(DataDir::auto())?;
36///
37/// // Detect PID from raw EDIFACT (no upfront knowledge needed)
38/// let pid = mapper.detect_pid(edifact_str)?;
39///
40/// // Convert to typed BO4E interchange
41/// let interchange: DynamicInterchange =
42///     mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", &pid)?;
43/// ```
44///
45/// # Outbound (BO4E → EDIFACT)
46///
47/// ```ignore
48/// let edifact = mapper.to_edifact(
49///     &msg_stammdaten, &tx_stammdaten,
50///     "FV2504", "UTILMD_Strom", "55001",
51/// )?;
52/// ```
53///
54/// # Mid-level Access
55///
56/// ```ignore
57/// let cs = mapper.conversion_service("FV2504", "UTILMD_Strom")?;
58/// let engine = mapper.engine("FV2504", "UTILMD_Strom", "55001")?;
59/// ```
60/// A single entry returned by [`Mapper::list_pids`].
61#[derive(Debug, Clone)]
62pub struct PidListEntry {
63    pub fv: String,
64    pub variant: String,
65    pub pid: String,
66    pub beschreibung: String,
67}
68
69pub struct Mapper {
70    data_dir: DataDir,
71    bundles: Mutex<HashMap<String, DataBundle>>,
72}
73
74impl Mapper {
75    /// Create a new `Mapper` from a [`DataDir`] configuration.
76    ///
77    /// Any format versions marked as [`eager`](DataDir::eager) are loaded immediately.
78    /// All others are loaded lazily on first access.
79    pub fn from_data_dir(data_dir: DataDir) -> Result<Self, MapperError> {
80        let mapper = Self {
81            data_dir,
82            bundles: Mutex::new(HashMap::new()),
83        };
84        let eager_fvs: Vec<String> = mapper.data_dir.eager_fvs().to_vec();
85        for fv in &eager_fvs {
86            mapper.ensure_bundle_loaded(fv)?;
87        }
88        Ok(mapper)
89    }
90
91    /// Ensure that the bundle for `fv` is loaded into memory.
92    fn ensure_bundle_loaded(&self, fv: &str) -> Result<(), MapperError> {
93        let mut bundles = self.bundles.lock().unwrap();
94        if bundles.contains_key(fv) {
95            return Ok(());
96        }
97        let path = self.data_dir.bundle_path(fv);
98        if !path.exists() {
99            return Err(MapperError::BundleNotFound { fv: fv.to_string() });
100        }
101        let bundle = DataBundle::load(&path)?;
102        bundles.insert(fv.to_string(), bundle);
103        Ok(())
104    }
105
106    /// Get a [`ConversionService`] for the given format version and variant.
107    ///
108    /// The service can tokenize EDIFACT input and assemble it into a MIG tree.
109    pub fn conversion_service(
110        &self,
111        fv: &str,
112        variant: &str,
113    ) -> Result<ConversionService, MapperError> {
114        self.ensure_bundle_loaded(fv)?;
115        let bundles = self.bundles.lock().unwrap();
116        let bundle = bundles.get(fv).unwrap();
117        let vc = bundle
118            .variant(variant)
119            .ok_or_else(|| MapperError::VariantNotFound {
120                fv: fv.to_string(),
121                variant: variant.to_string(),
122            })?;
123        let mig = vc
124            .mig_schema
125            .as_ref()
126            .ok_or_else(|| MapperError::VariantNotFound {
127                fv: fv.to_string(),
128                variant: format!("{variant} (no MIG schema in bundle)"),
129            })?;
130        Ok(ConversionService::from_mig(mig.clone()))
131    }
132
133    /// Get a [`MappingEngine`] for a specific PID within a format version and variant.
134    ///
135    /// The engine can convert between assembled MIG trees and BO4E JSON.
136    pub fn engine(&self, fv: &str, variant: &str, pid: &str) -> Result<MappingEngine, MapperError> {
137        self.ensure_bundle_loaded(fv)?;
138        let bundles = self.bundles.lock().unwrap();
139        let bundle = bundles.get(fv).unwrap();
140        let vc = bundle
141            .variant(variant)
142            .ok_or_else(|| MapperError::VariantNotFound {
143                fv: fv.to_string(),
144                variant: variant.to_string(),
145            })?;
146        let pid_key = format!("pid_{pid}");
147        let defs = vc
148            .combined_defs
149            .get(&pid_key)
150            .ok_or_else(|| MapperError::PidNotFound {
151                fv: fv.to_string(),
152                variant: variant.to_string(),
153                pid: pid.to_string(),
154            })?;
155        Ok(MappingEngine::from_definitions(defs.clone()))
156    }
157
158    /// Return the [`PidRequirements`] for a specific PID within a format version and variant.
159    ///
160    /// Requirements describe every entity and field the PID expects, including
161    /// AHB status, cardinality, valid code values, and message vs transaction scope.
162    pub fn pid_requirements(
163        &self,
164        fv: &str,
165        variant: &str,
166        pid: &str,
167    ) -> Result<mig_bo4e::pid_requirements::PidRequirements, MapperError> {
168        self.ensure_bundle_loaded(fv)?;
169        let bundles = self.bundles.lock().unwrap();
170        let bundle = bundles.get(fv).unwrap();
171        let vc = bundle
172            .variant(variant)
173            .ok_or_else(|| MapperError::VariantNotFound {
174                fv: fv.to_string(),
175                variant: variant.to_string(),
176            })?;
177        let pid_key = format!("pid_{pid}");
178        vc.pid_requirements
179            .get(&pid_key)
180            .cloned()
181            .ok_or_else(|| MapperError::PidNotFound {
182                fv: fv.to_string(),
183                variant: variant.to_string(),
184                pid: pid.to_string(),
185            })
186    }
187
188    /// Return the PID-agnostic [`Bo4eCatalog`] for a format version.
189    ///
190    /// The catalog contains one entry per BO4E type (BO, COM, Enum) parsed from
191    /// `bo4e-german` source at compile-mappings time. Used by Stammdatenaufbau in
192    /// downstream services.
193    pub fn bo4e_catalog(
194        &self,
195        fv: &str,
196    ) -> Result<mig_bo4e::bo4e_catalog::Bo4eCatalog, MapperError> {
197        self.ensure_bundle_loaded(fv)?;
198        let bundles = self.bundles.lock().unwrap();
199        let bundle = bundles.get(fv).unwrap();
200        Ok(bundle.bo4e_catalog.clone())
201    }
202
203    /// List all PIDs available across all format versions found in the data directory.
204    ///
205    /// Scans for `edifact-data-{FV}.bin` files, loads each bundle, and returns
206    /// one entry per PID per variant. Results are sorted by PID.
207    pub fn list_pids(&self) -> Result<Vec<PidListEntry>, MapperError> {
208        let dir = self.data_dir.data_path();
209        let read_dir = std::fs::read_dir(dir).map_err(|_| MapperError::DataDirNotFound {
210            path: dir.display().to_string(),
211        })?;
212
213        let mut result = Vec::new();
214
215        for entry in read_dir.flatten() {
216            let path = entry.path();
217            if path.extension().is_some_and(|e| e == "bin") {
218                let stem = path
219                    .file_stem()
220                    .and_then(|s| s.to_str())
221                    .unwrap_or("")
222                    .to_string();
223                let fv = match stem.strip_prefix("edifact-data-") {
224                    Some(v) => v.to_string(),
225                    None => continue,
226                };
227                self.ensure_bundle_loaded(&fv)?;
228                let bundles = self.bundles.lock().unwrap();
229                if let Some(bundle) = bundles.get(&fv) {
230                    for (variant, vc) in &bundle.variants {
231                        for (pid_key, req) in &vc.pid_requirements {
232                            let pid = pid_key.strip_prefix("pid_").unwrap_or(pid_key).to_string();
233                            result.push(PidListEntry {
234                                fv: fv.clone(),
235                                variant: variant.clone(),
236                                pid,
237                                beschreibung: req.beschreibung.clone(),
238                            });
239                        }
240                    }
241                }
242            }
243        }
244
245        result.sort_by(|a, b| a.pid.cmp(&b.pid));
246        Ok(result)
247    }
248
249    /// Validate a BO4E JSON object against PID requirements.
250    ///
251    /// Returns a list of validation errors. Empty list = valid.
252    /// The `json` should be the transaction-level stammdaten (the entity map).
253    pub fn validate_pid(
254        &self,
255        json: &serde_json::Value,
256        fv: &str,
257        variant: &str,
258        pid: &str,
259    ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
260        self.ensure_bundle_loaded(fv)?;
261        let bundles = self.bundles.lock().unwrap();
262        let bundle = bundles.get(fv).unwrap();
263        let vc = bundle
264            .variant(variant)
265            .ok_or_else(|| MapperError::VariantNotFound {
266                fv: fv.to_string(),
267                variant: variant.to_string(),
268            })?;
269        let pid_key = format!("pid_{pid}");
270        let requirements =
271            vc.pid_requirements
272                .get(&pid_key)
273                .ok_or_else(|| MapperError::PidNotFound {
274                    fv: fv.to_string(),
275                    variant: variant.to_string(),
276                    pid: pid.to_string(),
277                })?;
278
279        Ok(mig_bo4e::pid_validation::validate_pid_json(
280            json,
281            requirements,
282        ))
283    }
284
285    /// Validate a typed BO4E struct against PID requirements.
286    ///
287    /// Convenience wrapper that serializes the struct to JSON first.
288    /// Works with any `Pid*Interchange` or `Pid*MessageStammdaten` type.
289    ///
290    /// # Example
291    /// ```ignore
292    /// let interchange = build_55001_interchange();
293    /// let errors = mapper.validate_pid_struct(&interchange, "FV2504", "UTILMD_Strom", "55001")?;
294    /// assert!(errors.is_empty(), "Errors:\n{}", ValidationReport(errors));
295    /// ```
296    pub fn validate_pid_struct(
297        &self,
298        value: &impl serde::Serialize,
299        fv: &str,
300        variant: &str,
301        pid: &str,
302    ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
303        let json = serde_json::to_value(value).map_err(|e| {
304            MapperError::Mapping(mig_bo4e::MappingError::TypeConversion(e.to_string()))
305        })?;
306        self.validate_pid(&json, fv, variant, pid)
307    }
308
309    /// Validate with AHB condition awareness.
310    ///
311    /// Reverse-maps the JSON to EDIFACT segments, evaluates AHB conditions,
312    /// and reports fields as required/optional based on the actual data present.
313    ///
314    /// Falls back to basic validation (without conditions) if no condition
315    /// evaluator is available for the given variant/format version combination.
316    pub fn validate_pid_with_conditions(
317        &self,
318        json: &serde_json::Value,
319        fv: &str,
320        variant: &str,
321        pid: &str,
322    ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
323        self.ensure_bundle_loaded(fv)?;
324        let bundles = self.bundles.lock().unwrap();
325        let bundle = bundles.get(fv).unwrap();
326        let vc = bundle
327            .variant(variant)
328            .ok_or_else(|| MapperError::VariantNotFound {
329                fv: fv.to_string(),
330                variant: variant.to_string(),
331            })?;
332        let pid_key = format!("pid_{pid}");
333
334        let requirements =
335            vc.pid_requirements
336                .get(&pid_key)
337                .ok_or_else(|| MapperError::PidNotFound {
338                    fv: fv.to_string(),
339                    variant: variant.to_string(),
340                    pid: pid.to_string(),
341                })?;
342
343        // Try to get a condition evaluator for this variant
344        let evaluator = crate::evaluator_factory::create_evaluator(variant, fv);
345
346        if let Some(evaluator) = evaluator {
347            // Reverse-map JSON to EDIFACT segments for condition evaluation context
348            let defs = vc
349                .combined_defs
350                .get(&pid_key)
351                .ok_or_else(|| MapperError::PidNotFound {
352                    fv: fv.to_string(),
353                    variant: variant.to_string(),
354                    pid: pid.to_string(),
355                })?;
356            let engine = MappingEngine::from_definitions(defs.clone());
357            let tree = engine.map_all_reverse(json, None);
358
359            // Convert AssembledTree to flat OwnedSegments for EvaluationContext
360            let segments = crate::tree_to_segments::tree_to_owned_segments(&tree);
361
362            // Validate with condition awareness
363            Ok(crate::evaluator_factory::validate_with_boxed_evaluator(
364                evaluator.as_ref(),
365                json,
366                requirements,
367                pid,
368                &segments,
369            ))
370        } else {
371            // No evaluator available — fall back to basic validation
372            Ok(mig_bo4e::pid_validation::validate_pid_json_transaction(
373                json,
374                requirements,
375            ))
376        }
377    }
378
379    /// Convert BO4E JSON back to an EDIFACT string.
380    ///
381    /// Takes message-level stammdaten, a slice of per-transaction stammdaten,
382    /// and produces an EDIFACT message body (UNH through UNT content segments,
383    /// without UNB/UNZ interchange envelope).
384    ///
385    /// # Arguments
386    ///
387    /// * `msg_stammdaten` — message-level entities (e.g., Marktteilnehmer from SG2)
388    /// * `tx_stammdaten` — per-transaction entities (one per transaction/SG4 instance)
389    /// * `fv` — format version (e.g., "FV2504")
390    /// * `variant` — message variant (e.g., "UTILMD_Strom")
391    /// * `pid` — Pruefidentifikator (e.g., "55001")
392    ///
393    /// # Example
394    ///
395    /// ```ignore
396    /// let edifact = mapper.to_edifact(
397    ///     &msg_json,
398    ///     &[tx_json],
399    ///     "FV2504",
400    ///     "UTILMD_Strom",
401    ///     "55001",
402    /// )?;
403    /// ```
404    pub fn to_edifact(
405        &self,
406        msg_stammdaten: &serde_json::Value,
407        tx_stammdaten: &[serde_json::Value],
408        fv: &str,
409        variant: &str,
410        pid: &str,
411    ) -> Result<String, MapperError> {
412        self.ensure_bundle_loaded(fv)?;
413        let bundles = self.bundles.lock().unwrap();
414        let bundle = bundles.get(fv).unwrap();
415        let vc = bundle
416            .variant(variant)
417            .ok_or_else(|| MapperError::VariantNotFound {
418                fv: fv.to_string(),
419                variant: variant.to_string(),
420            })?;
421
422        let tx_group = vc.tx_group(pid).ok_or_else(|| MapperError::PidNotFound {
423            fv: fv.to_string(),
424            variant: variant.to_string(),
425            pid: pid.to_string(),
426        })?;
427
428        let msg_engine = vc.msg_engine(pid);
429        let tx_engine = vc.tx_engine(pid).ok_or_else(|| MapperError::PidNotFound {
430            fv: fv.to_string(),
431            variant: variant.to_string(),
432            pid: pid.to_string(),
433        })?;
434
435        let filtered_mig = vc
436            .filtered_mig(pid)
437            .ok_or_else(|| MapperError::NoMigSchema {
438                fv: fv.to_string(),
439                variant: variant.to_string(),
440            })?;
441
442        // Build MappedMessage from the provided JSON
443        let transaktionen: Vec<mig_bo4e::model::MappedTransaktion> = tx_stammdaten
444            .iter()
445            .map(|tx| mig_bo4e::model::MappedTransaktion {
446                stammdaten: tx.clone(),
447                nesting_info: Default::default(),
448                dp_routing: Default::default(),
449            })
450            .collect();
451        let mapped = mig_bo4e::model::MappedMessage {
452            stammdaten: msg_stammdaten.clone(),
453            transaktionen,
454            nesting_info: Default::default(),
455            dp_routing: Default::default(),
456            inter_group_segments: Default::default(),
457        };
458
459        // Reverse map → AssembledTree
460        let tree = MappingEngine::map_interchange_reverse(
461            &msg_engine,
462            &tx_engine,
463            &mapped,
464            tx_group,
465            Some(&filtered_mig),
466        );
467
468        // Disassemble → ordered segments
469        let disassembler = mig_assembly::disassembler::Disassembler::new(&filtered_mig);
470        let segments = disassembler.disassemble(&tree);
471
472        // Render to EDIFACT string with default delimiters
473        let delimiters = edifact_primitives::EdifactDelimiters::default();
474        Ok(mig_assembly::renderer::render_edifact(
475            &segments,
476            &delimiters,
477        ))
478    }
479
480    /// Convert a typed BO4E struct to an EDIFACT string.
481    ///
482    /// Convenience wrapper that serializes the struct to JSON first.
483    /// The struct should serialize to the `Nachricht` shape:
484    /// `{ "stammdaten": {...}, "transaktionen": [{...}] }`
485    pub fn to_edifact_struct(
486        &self,
487        nachricht: &impl serde::Serialize,
488        fv: &str,
489        variant: &str,
490        pid: &str,
491    ) -> Result<String, MapperError> {
492        let json = serde_json::to_value(nachricht)
493            .map_err(|e| MapperError::Serialization(e.to_string()))?;
494
495        let msg_stammdaten = json
496            .get("stammdaten")
497            .cloned()
498            .unwrap_or(serde_json::Value::Object(Default::default()));
499
500        let tx_stammdaten: Vec<serde_json::Value> = json
501            .get("transaktionen")
502            .and_then(|v| v.as_array())
503            .cloned()
504            .unwrap_or_default();
505
506        self.to_edifact(&msg_stammdaten, &tx_stammdaten, fv, variant, pid)
507    }
508
509    /// Parse an EDIFACT interchange string into a typed PID interchange struct.
510    ///
511    /// Runs the full pipeline: tokenize → split messages → assemble → forward-map → deserialize.
512    /// The type parameters `M` and `T` are the message-level and transaction-level
513    /// stammdaten types from the generated PID module.
514    ///
515    /// # Example
516    ///
517    /// ```ignore
518    /// use bo4e_edifact_types::generated::fv2504::utilmd::pids::pid_55001::*;
519    ///
520    /// let interchange: Interchange<Pid55001MsgStammdaten, Pid55001TxStammdaten> =
521    ///     mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", "55001")?;
522    ///
523    /// let tx = &interchange.nachrichten[0].transaktionen[0];
524    /// println!("Vorgang: {}", tx.prozessdaten.vorgang_id);
525    /// ```
526    pub fn from_edifact<M, T>(
527        &self,
528        edifact: &str,
529        fv: &str,
530        variant: &str,
531        pid: &str,
532    ) -> Result<mig_bo4e::model::Interchange<M, T>, MapperError>
533    where
534        M: serde::de::DeserializeOwned,
535        T: serde::de::DeserializeOwned,
536    {
537        self.ensure_bundle_loaded(fv)?;
538        let bundles = self.bundles.lock().unwrap();
539        let bundle = bundles.get(fv).unwrap();
540        let vc = bundle
541            .variant(variant)
542            .ok_or_else(|| MapperError::VariantNotFound {
543                fv: fv.to_string(),
544                variant: variant.to_string(),
545            })?;
546
547        let tx_group = vc.tx_group(pid).ok_or_else(|| MapperError::PidNotFound {
548            fv: fv.to_string(),
549            variant: variant.to_string(),
550            pid: pid.to_string(),
551        })?;
552
553        let msg_engine = vc.msg_engine(pid);
554        let tx_engine = vc.tx_engine(pid).ok_or_else(|| MapperError::PidNotFound {
555            fv: fv.to_string(),
556            variant: variant.to_string(),
557            pid: pid.to_string(),
558        })?;
559
560        let filtered_mig = vc
561            .filtered_mig(pid)
562            .ok_or_else(|| MapperError::NoMigSchema {
563                fv: fv.to_string(),
564                variant: variant.to_string(),
565            })?;
566
567        // Tokenize → split → assemble
568        let svc = ConversionService::from_mig(filtered_mig);
569        let (chunks, trees) = svc.convert_interchange_to_trees(edifact)?;
570
571        let tree = trees.first().ok_or_else(|| {
572            MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
573                "No messages in interchange".to_string(),
574            ))
575        })?;
576
577        // Extract envelope metadata
578        let interchangedaten = mig_bo4e::model::extract_interchangedaten(&chunks.envelope);
579        let msg_chunk = chunks.messages.first().ok_or_else(|| {
580            MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
581                "No message chunks".to_string(),
582            ))
583        })?;
584        let (unh_ref, nachrichten_typ) = mig_bo4e::model::extract_unh_fields(&msg_chunk.unh);
585        let nachrichtendaten = mig_bo4e::model::Nachrichtendaten {
586            unh_referenz: unh_ref,
587            nachrichten_typ,
588        };
589
590        // Forward-map to typed interchange
591        MappingEngine::map_interchange_typed::<M, T>(
592            &msg_engine,
593            &tx_engine,
594            tree,
595            tx_group,
596            true,
597            nachrichtendaten,
598            interchangedaten,
599        )
600        .map_err(|e| MapperError::Serialization(e.to_string()))
601    }
602
603    /// Detect the PID (Pruefidentifikator) from a raw EDIFACT interchange.
604    ///
605    /// Tokenizes the input, splits into messages, and extracts the PID from the
606    /// first message using the RFF+Z13 segment (primary) or BGM+STS fallback.
607    ///
608    /// This enables inbound message processing where the PID is not known upfront:
609    ///
610    /// ```ignore
611    /// let pid = mapper.detect_pid(edifact_str)?;
612    /// let interchange: MyType = mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", &pid)?;
613    /// ```
614    pub fn detect_pid(&self, edifact: &str) -> Result<String, MapperError> {
615        let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
616        let chunks = mig_assembly::split_messages(segments)?;
617        let msg_chunk = chunks.messages.first().ok_or_else(|| {
618            MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
619                "No messages found in EDIFACT content".to_string(),
620            ))
621        })?;
622        let msg_segments = msg_chunk.message_segments();
623        mig_assembly::pid_detect::detect_pid(&msg_segments).map_err(MapperError::Assembly)
624    }
625
626    /// Validate raw EDIFACT against its AHB rules.
627    ///
628    /// This is the same pipeline as the v2 API's `POST /api/v2/validate`
629    /// (`run_validation`) — both call [`validate_edifact_message`] — exposed here
630    /// as a library call so consumers (e.g. mako.hive) get full raw-EDIFACT
631    /// validation without running the API server. Detects the PID, resolves the
632    /// owning variant + its pre-built [`AhbWorkflow`] from the loaded bundle,
633    /// assembles the message, and runs the shared validation core.
634    ///
635    /// Requires the bundle for `fv` to carry `pid_ahb_workflows` (baked in at
636    /// compile-mappings). Returns [`MapperError::PidNotFound`] if no loaded variant
637    /// has a workflow for the detected PID.
638    ///
639    /// [`validate_edifact_message`]: automapper_validation::validate_edifact_message
640    /// [`AhbWorkflow`]: automapper_validation::AhbWorkflow
641    pub fn validate_edifact(
642        &self,
643        edifact: &str,
644        fv: &str,
645        level: automapper_validation::ValidationLevel,
646    ) -> Result<automapper_validation::ValidationReport, MapperError> {
647        self.ensure_bundle_loaded(fv)?;
648        let bundles = self.bundles.lock().unwrap();
649        let bundle = bundles.get(fv).unwrap();
650
651        // Tokenize → split → first message (same as `detect_pid`).
652        let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
653        let chunks = mig_assembly::split_messages(segments)?;
654        let msg_chunk = chunks.messages.first().ok_or_else(|| {
655            MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
656                "No messages found in EDIFACT content".to_string(),
657            ))
658        })?;
659
660        let pid = mig_assembly::pid_detect::detect_pid(&msg_chunk.message_segments())
661            .map_err(MapperError::Assembly)?;
662
663        // Resolve which loaded variant owns this PID via its pre-built workflow.
664        let pid_key = format!("pid_{pid}");
665        let (variant, vc) = bundle
666            .variants
667            .iter()
668            .find(|(_, vc)| vc.pid_ahb_workflows.contains_key(&pid_key))
669            .ok_or_else(|| MapperError::PidNotFound {
670                fv: fv.to_string(),
671                variant: "?".to_string(),
672                pid: pid.clone(),
673            })?;
674
675        let workflow = vc
676            .pid_ahb_workflows
677            .get(&pid_key)
678            .expect("presence checked by find() above");
679        let filtered_mig = vc
680            .filtered_mig(&pid)
681            .ok_or_else(|| MapperError::NoMigSchema {
682                fv: fv.to_string(),
683                variant: variant.clone(),
684            })?;
685
686        // Segments the validator sees: this message's body for the filtered MIG,
687        // plus the interchange UNZ when the MIG covers it (e.g. MSCONS).
688        let mut all_segments = msg_chunk.segments_for_mig(&filtered_mig);
689        if filtered_mig.segments.iter().any(|s| s.id == "UNZ") {
690            if let Some(unz) = &chunks.unz {
691                all_segments.push(unz.clone());
692            }
693        }
694
695        // Same evaluator resolution + fallback the v2 route uses. The explicit
696        // target type lets each arm coerce (Box<dyn> → Arc<dyn>; Arc<Concrete> →
697        // Arc<dyn> unsize) — a `.map(Arc::from)` chain can't infer that.
698        let evaluator: std::sync::Arc<dyn automapper_validation::ConditionEvaluator> =
699            match crate::evaluator_factory::create_evaluator(variant, fv) {
700                Some(boxed) => std::sync::Arc::from(boxed),
701                None => std::sync::Arc::new(
702                    automapper_validation::UtilmdStromConditionEvaluatorFV2504::default(),
703                ),
704            };
705        let external = automapper_validation::eval::NoOpExternalProvider;
706
707        Ok(automapper_validation::validate_edifact_message(
708            &all_segments,
709            &filtered_mig,
710            workflow,
711            evaluator,
712            &external,
713            level,
714        ))
715    }
716
717    /// Get the UNH association code for a variant (e.g., `"S2.1"`, `"2.4c"`).
718    ///
719    /// This is the version string from the MIG schema, used as the last component
720    /// of the UNH S009 composite: `UTILMD:D:11A:UN:S2.1`.
721    ///
722    /// # Example
723    /// ```ignore
724    /// let code = mapper.association_code("FV2604", "UTILMD_Strom")?;
725    /// assert_eq!(code, "S2.1");
726    /// ```
727    pub fn association_code(&self, fv: &str, variant: &str) -> Result<String, MapperError> {
728        let meta = self.message_metadata(fv, variant)?;
729        Ok(meta.association_code)
730    }
731
732    /// Get full message metadata for a variant, including the UNH S009 components.
733    ///
734    /// Returns the message type, UN/EDIFACT release code, and association code
735    /// needed to construct UNH segments.
736    pub fn message_metadata(
737        &self,
738        fv: &str,
739        variant: &str,
740    ) -> Result<MessageMetadata, MapperError> {
741        self.ensure_bundle_loaded(fv)?;
742        let bundles = self.bundles.lock().unwrap();
743        let bundle = bundles.get(fv).unwrap();
744        let vc = bundle
745            .variant(variant)
746            .ok_or_else(|| MapperError::VariantNotFound {
747                fv: fv.to_string(),
748                variant: variant.to_string(),
749            })?;
750        let mig = vc
751            .mig_schema
752            .as_ref()
753            .ok_or_else(|| MapperError::NoMigSchema {
754                fv: fv.to_string(),
755                variant: variant.to_string(),
756            })?;
757        Ok(MessageMetadata {
758            message_type: mig.message_type.clone(),
759            release: release_code_for_message_type(&mig.message_type),
760            association_code: mig.version.clone(),
761        })
762    }
763
764    /// Convert BO4E JSON to a complete EDIFACT interchange with envelope segments.
765    ///
766    /// Produces a full interchange including UNA, UNB, UNH, message body, UNT, and UNZ.
767    ///
768    /// # Example
769    /// ```ignore
770    /// let edifact = mapper.to_edifact_interchange(
771    ///     &InterchangeEnvelope {
772    ///         sender: EdifactParty::bdew("9900000000003"),
773    ///         receiver: EdifactParty::bdew("9900000000001"),
774    ///         interchange_ref: "REF001".to_string(),
775    ///     },
776    ///     &[InterchangeMessage {
777    ///         message_ref: "MSG001".to_string(),
778    ///         msg_stammdaten: serde_json::json!({"marktteilnehmer": []}),
779    ///         tx_stammdaten: vec![serde_json::json!({"prozessdaten": {"pruefidentifikator": "55001"}})],
780    ///         fv: "FV2604".to_string(),
781    ///         variant: "UTILMD_Strom".to_string(),
782    ///         pid: "55001".to_string(),
783    ///     }],
784    /// )?;
785    /// assert!(edifact.starts_with("UNA:+.? '"));
786    /// ```
787    pub fn to_edifact_interchange(
788        &self,
789        envelope: &InterchangeEnvelope,
790        messages: &[InterchangeMessage],
791    ) -> Result<String, MapperError> {
792        let delimiters = edifact_primitives::EdifactDelimiters::default();
793        let sep = delimiters.component as char;
794        let elem = delimiters.element as char;
795        let seg_term = delimiters.segment as char;
796
797        let mut output = String::new();
798
799        // UNA — Service string advice
800        output.push_str(&format!(
801            "UNA{}{}{}{}{}{}",
802            sep,                        // component separator
803            elem,                       // element separator
804            delimiters.decimal as char, // decimal notation
805            delimiters.release as char, // release/escape character
806            ' ',                        // reserved (space)
807            seg_term,                   // segment terminator
808        ));
809
810        // UNB — Interchange header
811        let now = chrono::Utc::now();
812        let date_str = now.format("%y%m%d").to_string();
813        let time_str = now.format("%H%M").to_string();
814        let sender = &envelope.sender;
815        let receiver = &envelope.receiver;
816        let interchange_ref = &envelope.interchange_ref;
817        output.push_str(&format!(
818            "UNB{elem}UNOC{sep}3{elem}{sid}{sep}{sq}{elem}{rid}{sep}{rq}{elem}{date_str}{sep}{time_str}{elem}{interchange_ref}{seg_term}",
819            sid = sender.id,
820            sq = sender.qualifier,
821            rid = receiver.id,
822            rq = receiver.qualifier,
823        ));
824
825        let mut message_count = 0u32;
826
827        for msg in messages {
828            let meta = self.message_metadata(&msg.fv, &msg.variant)?;
829
830            // Generate body segments
831            let body = self.to_edifact(
832                &msg.msg_stammdaten,
833                &msg.tx_stammdaten,
834                &msg.fv,
835                &msg.variant,
836                &msg.pid,
837            )?;
838
839            // Count segments in body (split by segment terminator, filter empty)
840            let body_seg_count = body
841                .split(seg_term)
842                .filter(|s: &&str| !s.is_empty())
843                .count();
844            // UNH + body segments + UNT = total segment count
845            let segment_count = body_seg_count + 2;
846
847            // UNH — Message header
848            output.push_str(&format!(
849                "UNH{elem}{ref}{elem}{msg_type}{sep}D{sep}{release}{sep}UN{sep}{assoc}{seg_term}",
850                ref = msg.message_ref,
851                msg_type = meta.message_type,
852                release = meta.release,
853                assoc = meta.association_code,
854            ));
855
856            // Body segments
857            output.push_str(&body);
858
859            // UNT — Message trailer
860            output.push_str(&format!(
861                "UNT{elem}{segment_count}{elem}{ref}{seg_term}",
862                ref = msg.message_ref,
863            ));
864
865            message_count += 1;
866        }
867
868        // UNZ — Interchange trailer
869        output.push_str(&format!(
870            "UNZ{elem}{message_count}{elem}{interchange_ref}{seg_term}",
871        ));
872
873        Ok(output)
874    }
875
876    /// List all format versions currently loaded in memory.
877    pub fn loaded_format_versions(&self) -> Vec<String> {
878        self.bundles.lock().unwrap().keys().cloned().collect()
879    }
880
881    /// List all variants available in a format version's bundle.
882    ///
883    /// Loads the bundle if not already loaded.
884    pub fn variants(&self, fv: &str) -> Result<Vec<String>, MapperError> {
885        self.ensure_bundle_loaded(fv)?;
886        let bundles = self.bundles.lock().unwrap();
887        let bundle = bundles.get(fv).unwrap();
888        Ok(bundle.variants.keys().cloned().collect())
889    }
890}
891
892/// Metadata about a message type needed for constructing UNH segments.
893#[derive(Debug, Clone)]
894pub struct MessageMetadata {
895    /// EDIFACT message type (e.g., `"UTILMD"`, `"MSCONS"`).
896    pub message_type: String,
897    /// UN/EDIFACT directory release code (e.g., `"11A"`, `"04B"`).
898    pub release: String,
899    /// Association-assigned code / MIG version (e.g., `"S2.1"`, `"2.4c"`).
900    pub association_code: String,
901}
902
903/// Envelope parameters for [`Mapper::to_edifact_interchange`].
904#[derive(Debug, Clone)]
905pub struct InterchangeEnvelope {
906    /// Sender party (UNB S002).
907    pub sender: EdifactParty,
908    /// Receiver party (UNB S003).
909    pub receiver: EdifactParty,
910    /// Unique interchange reference (UNB 0020 / UNZ 0020).
911    pub interchange_ref: String,
912}
913
914/// An EDIFACT interchange party (sender or receiver) with codelist qualifier.
915#[derive(Debug, Clone)]
916pub struct EdifactParty {
917    /// Party identification (e.g., MP-ID `"9900000000003"` or GLN `"4045458000000"`).
918    pub id: String,
919    /// Codelist qualifier: `"500"` = BDEW, `"14"` = GS1/EAN.
920    pub qualifier: String,
921}
922
923impl EdifactParty {
924    /// Create a party with BDEW codelist qualifier (500).
925    pub fn bdew(id: &str) -> Self {
926        Self {
927            id: id.to_string(),
928            qualifier: "500".to_string(),
929        }
930    }
931
932    /// Create a party with GS1/EAN codelist qualifier (14).
933    pub fn gs1(id: &str) -> Self {
934        Self {
935            id: id.to_string(),
936            qualifier: "14".to_string(),
937        }
938    }
939}
940
941/// A single message to include in an interchange built by
942/// [`Mapper::to_edifact_interchange`].
943#[derive(Debug, Clone)]
944pub struct InterchangeMessage {
945    /// Unique message reference number (used in UNH/UNT).
946    pub message_ref: String,
947    /// Message-level stammdaten (e.g., marktteilnehmer).
948    pub msg_stammdaten: serde_json::Value,
949    /// Transaction-level stammdaten (one per transaction).
950    pub tx_stammdaten: Vec<serde_json::Value>,
951    /// Format version (e.g., `"FV2604"`).
952    pub fv: String,
953    /// Message variant (e.g., `"UTILMD_Strom"`).
954    pub variant: String,
955    /// Pruefidentifikator (e.g., `"55001"`).
956    pub pid: String,
957}
958
959/// UN/EDIFACT directory release code for a message type.
960///
961/// These are stable per-message-type constants from the BDEW/DVGW specifications.
962fn release_code_for_message_type(msg_type: &str) -> String {
963    match msg_type {
964        "APERAK" => "07B",
965        "COMDIS" => "17A",
966        "CONTRL" => "04B",
967        "IFTSTA" => "18A",
968        "INSRPT" => "18A",
969        "INVOIC" => "06A",
970        "MSCONS" => "04B",
971        "ORDCHG" => "09B",
972        "ORDERS" => "09B",
973        "ORDRSP" => "10A",
974        "PARTIN" => "20B",
975        "PRICAT" => "20B",
976        "QUOTES" => "10A",
977        "REMADV" => "05A",
978        "REQOTE" => "10A",
979        "UTILMD" => "11A",
980        "UTILTS" => "18A",
981        _ => "04B", // fallback
982    }
983    .to_string()
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use std::path::Path;
990
991    fn data_dir() -> Option<std::path::PathBuf> {
992        // Try dist/ first (pre-built data bundles), then cache/mappings/
993        let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist");
994        if dist.join("edifact-data-FV2504.bin").exists() {
995            return Some(dist);
996        }
997        let cache = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cache/mappings");
998        if cache.join("FV2504").exists() {
999            return Some(cache);
1000        }
1001        eprintln!("Skipping test: no DataBundle files found");
1002        None
1003    }
1004
1005    #[test]
1006    fn test_to_edifact_produces_edifact_output() {
1007        let Some(data_dir) = data_dir() else {
1008            return;
1009        };
1010        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1011
1012        let msg_stammdaten = serde_json::json!({
1013            "marktteilnehmer": [{
1014                "marktrolle": "MS",
1015                "rollencodenummer": "9900123456789",
1016                "codepflegeCode": "293"
1017            }]
1018        });
1019        let tx_stammdaten = serde_json::json!({
1020            "prozessdaten": {
1021                "pruefidentifikator": "55001",
1022                "vorgangId": "ABC123",
1023                "transaktionsgrund": "E01"
1024            }
1025        });
1026
1027        let result = mapper.to_edifact(
1028            &msg_stammdaten,
1029            &[tx_stammdaten],
1030            "FV2504",
1031            "UTILMD_Strom",
1032            "55001",
1033        );
1034        assert!(result.is_ok(), "to_edifact failed: {:?}", result.err());
1035        let edifact = result.unwrap();
1036        assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1037        // Should produce NAD segment from marktteilnehmer
1038        assert!(edifact.contains("NAD"), "Should contain NAD segment");
1039        // Should produce IDE segment from prozessdaten
1040        assert!(edifact.contains("IDE"), "Should contain IDE segment");
1041    }
1042
1043    #[test]
1044    fn test_to_edifact_struct_produces_edifact_output() {
1045        let Some(data_dir) = data_dir() else {
1046            return;
1047        };
1048        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1049
1050        let nachricht = serde_json::json!({
1051            "stammdaten": {
1052                "marktteilnehmer": [{
1053                    "marktrolle": "MS",
1054                    "rollencodenummer": "9900123456789",
1055                    "codepflegeCode": "293"
1056                }]
1057            },
1058            "transaktionen": [{
1059                "prozessdaten": {
1060                    "pruefidentifikator": "55001",
1061                    "vorgangId": "ABC123"
1062                }
1063            }]
1064        });
1065
1066        let result = mapper.to_edifact_struct(&nachricht, "FV2504", "UTILMD_Strom", "55001");
1067        assert!(
1068            result.is_ok(),
1069            "to_edifact_struct failed: {:?}",
1070            result.err()
1071        );
1072        let edifact = result.unwrap();
1073        assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1074    }
1075
1076    #[test]
1077    fn test_to_edifact_invalid_fv_returns_error() {
1078        let Some(data_dir) = data_dir() else {
1079            return;
1080        };
1081        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1082
1083        let result = mapper.to_edifact(
1084            &serde_json::json!({}),
1085            &[serde_json::json!({})],
1086            "FV9999",
1087            "UTILMD_Strom",
1088            "55001",
1089        );
1090        assert!(result.is_err());
1091    }
1092
1093    #[test]
1094    fn test_to_edifact_invalid_variant_returns_error() {
1095        let Some(data_dir) = data_dir() else {
1096            return;
1097        };
1098        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1099
1100        let result = mapper.to_edifact(
1101            &serde_json::json!({}),
1102            &[serde_json::json!({})],
1103            "FV2504",
1104            "NONEXISTENT",
1105            "55001",
1106        );
1107        assert!(result.is_err());
1108    }
1109
1110    #[test]
1111    fn test_to_edifact_invalid_pid_returns_error() {
1112        let Some(data_dir) = data_dir() else {
1113            return;
1114        };
1115        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1116
1117        let result = mapper.to_edifact(
1118            &serde_json::json!({}),
1119            &[serde_json::json!({})],
1120            "FV2504",
1121            "UTILMD_Strom",
1122            "99999",
1123        );
1124        assert!(result.is_err());
1125    }
1126
1127    #[test]
1128    fn test_association_code() {
1129        let Some(data_dir) = data_dir() else {
1130            return;
1131        };
1132        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1133
1134        let code = mapper.association_code("FV2504", "UTILMD_Strom").unwrap();
1135        assert_eq!(code, "S2.1");
1136
1137        let code = mapper.association_code("FV2504", "MSCONS").unwrap();
1138        assert_eq!(code, "2.4c");
1139    }
1140
1141    #[test]
1142    fn test_message_metadata() {
1143        let Some(data_dir) = data_dir() else {
1144            return;
1145        };
1146        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1147
1148        let meta = mapper.message_metadata("FV2504", "UTILMD_Strom").unwrap();
1149        assert_eq!(meta.message_type, "UTILMD");
1150        assert_eq!(meta.release, "11A");
1151        assert_eq!(meta.association_code, "S2.1");
1152    }
1153
1154    #[test]
1155    fn test_to_edifact_interchange() {
1156        let Some(data_dir) = data_dir() else {
1157            return;
1158        };
1159        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1160
1161        let result = mapper.to_edifact_interchange(
1162            &InterchangeEnvelope {
1163                sender: EdifactParty::bdew("9900000000003"),
1164                receiver: EdifactParty::bdew("9900000000001"),
1165                interchange_ref: "REF001".to_string(),
1166            },
1167            &[InterchangeMessage {
1168                message_ref: "MSG001".to_string(),
1169                msg_stammdaten: serde_json::json!({
1170                    "marktteilnehmer": [{
1171                        "marktrolle": "MS",
1172                        "rollencodenummer": "9900123456789",
1173                        "codepflegeCode": "293"
1174                    }]
1175                }),
1176                tx_stammdaten: vec![serde_json::json!({
1177                    "prozessdaten": {
1178                        "pruefidentifikator": "55001",
1179                        "vorgangId": "ABC123",
1180                        "transaktionsgrund": "E01"
1181                    }
1182                })],
1183                fv: "FV2504".to_string(),
1184                variant: "UTILMD_Strom".to_string(),
1185                pid: "55001".to_string(),
1186            }],
1187        );
1188        assert!(
1189            result.is_ok(),
1190            "to_edifact_interchange failed: {:?}",
1191            result.err()
1192        );
1193        let edifact = result.unwrap();
1194
1195        // Verify envelope structure
1196        assert!(edifact.starts_with("UNA:+.? '"), "Should start with UNA");
1197        assert!(
1198            edifact.contains("UNB+UNOC:3+9900000000003:500+9900000000001:500+"),
1199            "Should contain UNB with sender/receiver"
1200        );
1201        assert!(
1202            edifact.contains("UNH+MSG001+UTILMD:D:11A:UN:S2.1'"),
1203            "Should contain UNH with correct S009"
1204        );
1205        assert!(edifact.contains("NAD"), "Should contain body NAD segment");
1206        assert!(edifact.contains("UNT+"), "Should contain UNT");
1207        assert!(
1208            edifact.contains("+MSG001'"),
1209            "UNT should reference message ref"
1210        );
1211        assert!(
1212            edifact.contains("UNZ+1+REF001'"),
1213            "Should contain UNZ with count and ref"
1214        );
1215    }
1216
1217    #[test]
1218    fn test_detect_pid_from_rff_z13() {
1219        let Some(data_dir) = data_dir() else {
1220            return;
1221        };
1222        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1223
1224        let edifact = "\
1225            UNB+UNOC:3+9978842000002:500+9900269000000:500+250331:1329+REF001'\
1226            UNH+MSG001+UTILMD:D:11A:UN:S2.1'\
1227            BGM+E01+DOC001'\
1228            DTM+137:202503311329?+00:303'\
1229            NAD+MS+9978842000002::293'\
1230            NAD+MR+9900269000000::293'\
1231            IDE+24+TX001'\
1232            DTM+92:202505312200?+00:303'\
1233            DTM+93:202512312300?+00:303'\
1234            STS+7++E01+ZW4+E03'\
1235            LOC+Z16+12345678900'\
1236            RFF+Z13:55001'\
1237            UNT+12+MSG001'\
1238            UNZ+1+REF001'";
1239
1240        let pid = mapper.detect_pid(edifact).unwrap();
1241        assert_eq!(pid, "55001");
1242    }
1243
1244    #[test]
1245    fn test_detect_pid_no_messages_returns_error() {
1246        let Some(data_dir) = data_dir() else {
1247            return;
1248        };
1249        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1250
1251        let edifact = "UNB+UNOC:3+SENDER:500+RECEIVER:500+250401:1200+REF'\
1252                        UNZ+0+REF'";
1253        assert!(mapper.detect_pid(edifact).is_err());
1254    }
1255
1256    #[test]
1257    fn test_list_pids_returns_entries() {
1258        let Some(data_dir) = data_dir() else {
1259            return;
1260        };
1261        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir)).unwrap();
1262        let pids = mapper.list_pids().expect("list_pids should succeed");
1263        assert!(!pids.is_empty(), "should return at least one PID");
1264        assert!(
1265            pids.iter().any(|p| p.pid == "55001"),
1266            "should include PID 55001"
1267        );
1268        assert!(
1269            pids.iter().any(|p| p.fv == "FV2504"),
1270            "should include FV2504"
1271        );
1272        assert!(
1273            pids.iter().any(|p| p.variant == "UTILMD_Strom"),
1274            "should include UTILMD_Strom"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_pid_requirements_returns_requirements() {
1280        let Some(data_dir) = data_dir() else {
1281            return;
1282        };
1283        let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1284
1285        let req = mapper
1286            .pid_requirements("FV2504", "UTILMD_Strom", "55001")
1287            .expect("pid_requirements should succeed");
1288
1289        assert_eq!(req.pid, "55001");
1290        assert!(
1291            !req.entities.is_empty(),
1292            "55001 should have at least one entity"
1293        );
1294        assert!(
1295            req.entities.iter().any(|e| e.entity == "Prozessdaten"),
1296            "55001 should have a Prozessdaten entity"
1297        );
1298    }
1299}