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
74/// Read one caller-supplied transaction into a [`mig_bo4e::model::MappedTransaktion`].
75///
76/// Accepts both shapes. A `{transaktionsdaten, stammdaten}` object is taken
77/// apart into the two halves; anything else is a bare entity map, which is what
78/// callers passed before the metadata slot existed — including one that already
79/// contains `prozessdaten` among its entities, where the engine's own reverse
80/// merge handles it.
81///
82/// Either key identifies the wrapper — see
83/// [`is_wrapped_transaktion`](mig_bo4e::model::is_wrapped_transaktion). A half
84/// that is absent stands in as empty, so a transaction of metadata alone keeps
85/// its metadata instead of being read as an entity map (issue #153).
86fn split_transaktion(tx: &serde_json::Value) -> mig_bo4e::model::MappedTransaktion {
87 let (transaktionsdaten, stammdaten) = if mig_bo4e::model::is_wrapped_transaktion(tx) {
88 (
89 tx.get("transaktionsdaten")
90 .cloned()
91 .unwrap_or(serde_json::Value::Null),
92 tx.get("stammdaten")
93 .cloned()
94 .unwrap_or_else(|| serde_json::Value::Object(Default::default())),
95 )
96 } else {
97 (serde_json::Value::Null, tx.clone())
98 };
99 mig_bo4e::model::MappedTransaktion {
100 transaktionsdaten,
101 stammdaten,
102 nesting_info: Default::default(),
103 }
104}
105
106impl Mapper {
107 /// Create a new `Mapper` from a [`DataDir`] configuration.
108 ///
109 /// Any format versions marked as [`eager`](DataDir::eager) are loaded immediately.
110 /// All others are loaded lazily on first access.
111 pub fn from_data_dir(data_dir: DataDir) -> Result<Self, MapperError> {
112 let mapper = Self {
113 data_dir,
114 bundles: Mutex::new(HashMap::new()),
115 };
116 let eager_fvs: Vec<String> = mapper.data_dir.eager_fvs().to_vec();
117 for fv in &eager_fvs {
118 mapper.ensure_bundle_loaded(fv)?;
119 }
120 Ok(mapper)
121 }
122
123 /// Ensure that the bundle for `fv` is loaded into memory.
124 fn ensure_bundle_loaded(&self, fv: &str) -> Result<(), MapperError> {
125 let mut bundles = self.bundles.lock().unwrap();
126 if bundles.contains_key(fv) {
127 return Ok(());
128 }
129 let path = self.data_dir.bundle_path(fv);
130 if !path.exists() {
131 return Err(MapperError::BundleNotFound { fv: fv.to_string() });
132 }
133 let bundle = DataBundle::load(&path)?;
134 // `DataBundle::load` has already checked the serialisation format.
135 // That says the file parses, not that its mappings belong with this
136 // crate — the check that would have caught #158.
137 let expected = DataBundle::PRODUCING_VERSION;
138 if !self.data_dir.allows_bundle_from_other_release()
139 && bundle.built_by.as_deref() != Some(expected)
140 {
141 return Err(MapperError::BundleFromOtherRelease {
142 fv: fv.to_string(),
143 built_by: bundle.built_by.clone(),
144 expected: expected.to_string(),
145 path: path.display().to_string(),
146 });
147 }
148 bundles.insert(fv.to_string(), bundle);
149 Ok(())
150 }
151
152 /// Get a [`ConversionService`] for the given format version and variant.
153 ///
154 /// The service can tokenize EDIFACT input and assemble it into a MIG tree.
155 pub fn conversion_service(
156 &self,
157 fv: &str,
158 variant: &str,
159 ) -> Result<ConversionService, 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 mig = vc
170 .mig_schema
171 .as_ref()
172 .ok_or_else(|| MapperError::VariantNotFound {
173 fv: fv.to_string(),
174 variant: format!("{variant} (no MIG schema in bundle)"),
175 })?;
176 Ok(ConversionService::from_mig(mig.clone()))
177 }
178
179 /// Get a [`MappingEngine`] for a specific PID within a format version and variant.
180 ///
181 /// The engine can convert between assembled MIG trees and BO4E JSON.
182 pub fn engine(&self, fv: &str, variant: &str, pid: &str) -> Result<MappingEngine, MapperError> {
183 self.ensure_bundle_loaded(fv)?;
184 let bundles = self.bundles.lock().unwrap();
185 let bundle = bundles.get(fv).unwrap();
186 let vc = bundle
187 .variant(variant)
188 .ok_or_else(|| MapperError::VariantNotFound {
189 fv: fv.to_string(),
190 variant: variant.to_string(),
191 })?;
192 let pid_key = format!("pid_{pid}");
193 let defs = vc
194 .combined_defs
195 .get(&pid_key)
196 .ok_or_else(|| MapperError::PidNotFound {
197 fv: fv.to_string(),
198 variant: variant.to_string(),
199 pid: pid.to_string(),
200 })?;
201 Ok(MappingEngine::from_definitions_with_code_lists(
202 std::sync::Arc::clone(&vc.code_lists),
203 defs.clone(),
204 ))
205 }
206
207 /// Return the [`PidRequirements`] for a specific PID within a format version and variant.
208 ///
209 /// Requirements describe every entity and field the PID expects, including
210 /// AHB status, cardinality, valid code values, and message vs transaction scope.
211 pub fn pid_requirements(
212 &self,
213 fv: &str,
214 variant: &str,
215 pid: &str,
216 ) -> Result<mig_bo4e::pid_requirements::PidRequirements, MapperError> {
217 self.ensure_bundle_loaded(fv)?;
218 let bundles = self.bundles.lock().unwrap();
219 let bundle = bundles.get(fv).unwrap();
220 let vc = bundle
221 .variant(variant)
222 .ok_or_else(|| MapperError::VariantNotFound {
223 fv: fv.to_string(),
224 variant: variant.to_string(),
225 })?;
226 let pid_key = format!("pid_{pid}");
227 vc.pid_requirements
228 .get(&pid_key)
229 .cloned()
230 .ok_or_else(|| MapperError::PidNotFound {
231 fv: fv.to_string(),
232 variant: variant.to_string(),
233 pid: pid.to_string(),
234 })
235 }
236
237 /// Return the PID-agnostic [`Bo4eCatalog`] for a format version.
238 ///
239 /// The catalog contains one entry per BO4E type (BO, COM, Enum) parsed from
240 /// `bo4e-german` source at compile-mappings time. Used by Stammdatenaufbau in
241 /// downstream services.
242 pub fn bo4e_catalog(
243 &self,
244 fv: &str,
245 ) -> Result<mig_bo4e::bo4e_catalog::Bo4eCatalog, MapperError> {
246 self.ensure_bundle_loaded(fv)?;
247 let bundles = self.bundles.lock().unwrap();
248 let bundle = bundles.get(fv).unwrap();
249 Ok(bundle.bo4e_catalog.clone())
250 }
251
252 /// List all PIDs available across all format versions found in the data directory.
253 ///
254 /// Scans for `edifact-data-{FV}.bin` files, loads each bundle, and returns
255 /// one entry per PID per variant. Results are sorted by PID.
256 pub fn list_pids(&self) -> Result<Vec<PidListEntry>, MapperError> {
257 let dir = self.data_dir.data_path();
258 let read_dir = std::fs::read_dir(dir).map_err(|_| MapperError::DataDirNotFound {
259 path: dir.display().to_string(),
260 })?;
261
262 let mut result = Vec::new();
263
264 for entry in read_dir.flatten() {
265 let path = entry.path();
266 if path.extension().is_some_and(|e| e == "bin") {
267 let stem = path
268 .file_stem()
269 .and_then(|s| s.to_str())
270 .unwrap_or("")
271 .to_string();
272 let fv = match stem.strip_prefix("edifact-data-") {
273 Some(v) => v.to_string(),
274 None => continue,
275 };
276 self.ensure_bundle_loaded(&fv)?;
277 let bundles = self.bundles.lock().unwrap();
278 if let Some(bundle) = bundles.get(&fv) {
279 for (variant, vc) in &bundle.variants {
280 for (pid_key, req) in &vc.pid_requirements {
281 let pid = pid_key.strip_prefix("pid_").unwrap_or(pid_key).to_string();
282 result.push(PidListEntry {
283 fv: fv.clone(),
284 variant: variant.clone(),
285 pid,
286 beschreibung: req.beschreibung.clone(),
287 });
288 }
289 }
290 }
291 }
292 }
293
294 result.sort_by(|a, b| a.pid.cmp(&b.pid));
295 Ok(result)
296 }
297
298 /// Validate a BO4E JSON object against PID requirements.
299 ///
300 /// Returns a list of validation errors. Empty list = valid.
301 /// The `json` should be the transaction-level stammdaten (the entity map).
302 pub fn validate_pid(
303 &self,
304 json: &serde_json::Value,
305 fv: &str,
306 variant: &str,
307 pid: &str,
308 ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
309 self.ensure_bundle_loaded(fv)?;
310 let bundles = self.bundles.lock().unwrap();
311 let bundle = bundles.get(fv).unwrap();
312 let vc = bundle
313 .variant(variant)
314 .ok_or_else(|| MapperError::VariantNotFound {
315 fv: fv.to_string(),
316 variant: variant.to_string(),
317 })?;
318 let pid_key = format!("pid_{pid}");
319 let requirements =
320 vc.pid_requirements
321 .get(&pid_key)
322 .ok_or_else(|| MapperError::PidNotFound {
323 fv: fv.to_string(),
324 variant: variant.to_string(),
325 pid: pid.to_string(),
326 })?;
327
328 Ok(mig_bo4e::pid_validation::validate_pid_json(
329 json,
330 requirements,
331 ))
332 }
333
334 /// Validate a typed BO4E struct against PID requirements.
335 ///
336 /// Convenience wrapper that serializes the struct to JSON first.
337 /// Works with any `Pid*Interchange` or `Pid*MessageStammdaten` type.
338 ///
339 /// # Example
340 /// ```ignore
341 /// let interchange = build_55001_interchange();
342 /// let errors = mapper.validate_pid_struct(&interchange, "FV2504", "UTILMD_Strom", "55001")?;
343 /// assert!(errors.is_empty(), "Errors:\n{}", ValidationReport(errors));
344 /// ```
345 pub fn validate_pid_struct(
346 &self,
347 value: &impl serde::Serialize,
348 fv: &str,
349 variant: &str,
350 pid: &str,
351 ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
352 let json = serde_json::to_value(value).map_err(|e| {
353 MapperError::Mapping(mig_bo4e::MappingError::TypeConversion(e.to_string()))
354 })?;
355 self.validate_pid(&json, fv, variant, pid)
356 }
357
358 /// Validate with AHB condition awareness.
359 ///
360 /// Reverse-maps the JSON to EDIFACT segments, evaluates AHB conditions,
361 /// and reports fields as required/optional based on the actual data present.
362 ///
363 /// Falls back to basic validation (without conditions) if no condition
364 /// evaluator is available for the given variant/format version combination.
365 pub fn validate_pid_with_conditions(
366 &self,
367 json: &serde_json::Value,
368 fv: &str,
369 variant: &str,
370 pid: &str,
371 ) -> Result<Vec<mig_bo4e::PidValidationError>, MapperError> {
372 self.ensure_bundle_loaded(fv)?;
373 let bundles = self.bundles.lock().unwrap();
374 let bundle = bundles.get(fv).unwrap();
375 let vc = bundle
376 .variant(variant)
377 .ok_or_else(|| MapperError::VariantNotFound {
378 fv: fv.to_string(),
379 variant: variant.to_string(),
380 })?;
381 let pid_key = format!("pid_{pid}");
382
383 let requirements =
384 vc.pid_requirements
385 .get(&pid_key)
386 .ok_or_else(|| MapperError::PidNotFound {
387 fv: fv.to_string(),
388 variant: variant.to_string(),
389 pid: pid.to_string(),
390 })?;
391
392 // Try to get a condition evaluator for this variant
393 let evaluator = crate::evaluator_factory::create_evaluator(variant, fv);
394
395 if let Some(evaluator) = evaluator {
396 // Reverse-map JSON to EDIFACT segments for condition evaluation context
397 let defs = vc
398 .combined_defs
399 .get(&pid_key)
400 .ok_or_else(|| MapperError::PidNotFound {
401 fv: fv.to_string(),
402 variant: variant.to_string(),
403 pid: pid.to_string(),
404 })?;
405 let engine = MappingEngine::from_definitions_with_code_lists(
406 std::sync::Arc::clone(&vc.code_lists),
407 defs.clone(),
408 );
409 let tree = engine.map_all_reverse(json, None);
410
411 // Convert AssembledTree to flat OwnedSegments for EvaluationContext
412 let segments = crate::tree_to_segments::tree_to_owned_segments(&tree);
413
414 // Validate with condition awareness
415 Ok(crate::evaluator_factory::validate_with_boxed_evaluator(
416 evaluator.as_ref(),
417 json,
418 requirements,
419 pid,
420 &segments,
421 ))
422 } else {
423 // No evaluator available — fall back to basic validation
424 Ok(mig_bo4e::pid_validation::validate_pid_json_transaction(
425 json,
426 requirements,
427 ))
428 }
429 }
430
431 /// Convert BO4E JSON back to an EDIFACT string.
432 ///
433 /// Takes message-level stammdaten, a slice of per-transaction stammdaten,
434 /// and produces an EDIFACT message body (UNH through UNT content segments,
435 /// without UNB/UNZ interchange envelope).
436 ///
437 /// # Arguments
438 ///
439 /// * `msg_stammdaten` — message-level entities (e.g., Marktteilnehmer from SG2)
440 /// * `tx_stammdaten` — per-transaction entities (one per transaction/SG4 instance)
441 /// * `fv` — format version (e.g., "FV2504")
442 /// * `variant` — message variant (e.g., "UTILMD_Strom")
443 /// * `pid` — Pruefidentifikator (e.g., "55001")
444 ///
445 /// # Round-tripping output of [`from_edifact`](Self::from_edifact)
446 ///
447 /// `msg_stammdaten` is only half of what the forward direction produced.
448 /// The message header — `nachrichtentyp`, `nachrichtennummer`,
449 /// `erstellungsdatum`, i.e. the wire's `BGM` and `DTM+137` — is in
450 /// `nachrichtendaten`, not in `stammdaten`, so passing `stammdaten` alone
451 /// renders a body without its header and reports nothing (issue #158).
452 /// Use [`to_edifact_nachricht`](Self::to_edifact_nachricht), which takes
453 /// both halves.
454 ///
455 /// # Example
456 ///
457 /// ```ignore
458 /// let edifact = mapper.to_edifact(
459 /// &msg_json,
460 /// &[tx_json],
461 /// "FV2504",
462 /// "UTILMD_Strom",
463 /// "55001",
464 /// )?;
465 /// ```
466 ///
467 /// # Errors
468 ///
469 /// Besides lookup failures, returns [`MapperError::MissingGroupEntrySegment`]
470 /// when the BO4E fills some of a segment group's fields but not the one its
471 /// entry segment is built from — e.g. a `zaehler` with `geraeteNummer` but no
472 /// `zaehlertypMerkmal`, which would render SG10 `CAV` without `CCI`. Such a
473 /// message cannot be parsed back; its group content would be lost.
474 pub fn to_edifact(
475 &self,
476 msg_stammdaten: &serde_json::Value,
477 tx_stammdaten: &[serde_json::Value],
478 fv: &str,
479 variant: &str,
480 pid: &str,
481 ) -> Result<String, MapperError> {
482 self.render_message_body(
483 msg_stammdaten,
484 tx_stammdaten,
485 fv,
486 variant,
487 pid,
488 EntrySegmentCheck::Refuse,
489 )
490 }
491
492 /// Render one message body from a [`Nachricht`] as [`from_edifact`] produced it.
493 ///
494 /// The forward direction splits a message in two: the business objects go to
495 /// `stammdaten`, and the message header — `nachrichtentyp`,
496 /// `nachrichtennummer`, `erstellungsdatum`, which are the `BGM` and
497 /// `DTM+137` of the wire — goes to `nachrichtendaten` beside it.
498 /// [`to_edifact`] takes only the first half, so handing it `stammdaten`
499 /// alone renders a body without its header and says nothing (issue #158).
500 ///
501 /// This takes both, so a caller can give back what it was given:
502 ///
503 /// ```ignore
504 /// let interchange = mapper.from_edifact::<Value, Value>(&edifact, fv, variant, pid)?;
505 /// let body = mapper.to_edifact_nachricht(&interchange.nachrichten[0], fv, variant, pid)?;
506 /// ```
507 ///
508 /// Only the body: the `UNB`/`UNH`/`UNT`/`UNZ` envelope is
509 /// [`to_edifact_interchange`](Self::to_edifact_interchange)'s job.
510 ///
511 /// # Errors
512 ///
513 /// As [`to_edifact`].
514 ///
515 /// [`to_edifact`]: Self::to_edifact
516 /// [`from_edifact`]: Self::from_edifact
517 /// [`Nachricht`]: mig_bo4e::model::Nachricht
518 pub fn to_edifact_nachricht(
519 &self,
520 nachricht: &mig_bo4e::model::Nachricht<serde_json::Value, serde_json::Value>,
521 fv: &str,
522 variant: &str,
523 pid: &str,
524 ) -> Result<String, MapperError> {
525 let mut msg_stammdaten = nachricht.stammdaten.clone();
526 mig_bo4e::model::restore_message_metadata(&mut msg_stammdaten, &nachricht.nachrichtendaten);
527 self.to_edifact(&msg_stammdaten, &nachricht.transaktionen, fv, variant, pid)
528 }
529
530 /// Reverse-map and render one message body. `check` decides what happens to
531 /// a group instance lacking its MIG entry segment: [`to_edifact`] refuses
532 /// it, [`validate_bo4e`] renders it so the validator can report the defect
533 /// as findings instead of failing the whole validation.
534 ///
535 /// [`to_edifact`]: Self::to_edifact
536 /// [`validate_bo4e`]: Self::validate_bo4e
537 fn render_message_body(
538 &self,
539 msg_stammdaten: &serde_json::Value,
540 tx_stammdaten: &[serde_json::Value],
541 fv: &str,
542 variant: &str,
543 pid: &str,
544 check: EntrySegmentCheck,
545 ) -> Result<String, MapperError> {
546 self.ensure_bundle_loaded(fv)?;
547 let bundles = self.bundles.lock().unwrap();
548 let bundle = bundles.get(fv).unwrap();
549 let vc = bundle
550 .variant(variant)
551 .ok_or_else(|| MapperError::VariantNotFound {
552 fv: fv.to_string(),
553 variant: variant.to_string(),
554 })?;
555
556 let tx_group = vc.tx_group(pid).ok_or_else(|| MapperError::PidNotFound {
557 fv: fv.to_string(),
558 variant: variant.to_string(),
559 pid: pid.to_string(),
560 })?;
561
562 let msg_engine = vc.msg_engine(pid);
563 let tx_engine = vc.tx_engine(pid).ok_or_else(|| MapperError::PidNotFound {
564 fv: fv.to_string(),
565 variant: variant.to_string(),
566 pid: pid.to_string(),
567 })?;
568
569 let filtered_mig = vc
570 .filtered_mig(pid)
571 .ok_or_else(|| MapperError::NoMigSchema {
572 fv: fv.to_string(),
573 variant: variant.to_string(),
574 })?;
575
576 // Build MappedMessage from the provided JSON
577 let transaktionen: Vec<mig_bo4e::model::MappedTransaktion> =
578 tx_stammdaten.iter().map(split_transaktion).collect();
579 let mapped = mig_bo4e::model::MappedMessage {
580 nachricht_meta: serde_json::Value::Null,
581 stammdaten: msg_stammdaten.clone(),
582 transaktionen,
583 nesting_info: Default::default(),
584 inter_group_segments: Default::default(),
585 };
586
587 // Reverse map → AssembledTree
588 let tree = MappingEngine::map_interchange_reverse(
589 &msg_engine,
590 &tx_engine,
591 &mapped,
592 tx_group,
593 Some(&filtered_mig),
594 );
595
596 // Disassemble → ordered segments. A group instance whose MIG entry
597 // segment is missing (e.g. SG10 with CAV but no CCI because the BO4E
598 // lacks the field the CCI is built from) renders EDIFACT that no
599 // receiver can assemble, so by default it is refused (#103).
600 let disassembler = mig_assembly::disassembler::Disassembler::new(&filtered_mig);
601 let checked = match check {
602 EntrySegmentCheck::Refuse => disassembler.disassemble_checked(&tree),
603 EntrySegmentCheck::Render => Ok(disassembler.disassemble(&tree)),
604 };
605 let segments = checked.map_err(|e| match e {
606 mig_assembly::AssemblyError::MissingGroupEntrySegment {
607 group_path,
608 source_path,
609 entry_segment,
610 present_segments,
611 } => {
612 let (entities, entry_fields) = describe_entry_segment_mappings(
613 [msg_engine.definitions(), tx_engine.definitions()],
614 &source_path,
615 &entry_segment,
616 );
617 MapperError::MissingGroupEntrySegment(Box::new(
618 crate::error::GroupEntrySegmentError {
619 pid: pid.to_string(),
620 group_path,
621 source_path,
622 entry_segment,
623 present_segments,
624 entities,
625 entry_fields,
626 },
627 ))
628 }
629 other => MapperError::Assembly(other),
630 })?;
631
632 // Render to EDIFACT string with default delimiters
633 let delimiters = edifact_primitives::EdifactDelimiters::default();
634 Ok(mig_assembly::renderer::render_edifact(
635 &segments,
636 &delimiters,
637 ))
638 }
639
640 /// Convert a typed BO4E struct to an EDIFACT string.
641 ///
642 /// Convenience wrapper that serializes the struct to JSON first.
643 /// The struct should serialize to the `Nachricht` shape:
644 /// `{ "stammdaten": {...}, "transaktionen": [{...}] }`
645 pub fn to_edifact_struct(
646 &self,
647 nachricht: &impl serde::Serialize,
648 fv: &str,
649 variant: &str,
650 pid: &str,
651 ) -> Result<String, MapperError> {
652 let json = serde_json::to_value(nachricht)
653 .map_err(|e| MapperError::Serialization(e.to_string()))?;
654
655 let msg_stammdaten = json
656 .get("stammdaten")
657 .cloned()
658 .unwrap_or(serde_json::Value::Object(Default::default()));
659
660 let tx_stammdaten: Vec<serde_json::Value> = json
661 .get("transaktionen")
662 .and_then(|v| v.as_array())
663 .cloned()
664 .unwrap_or_default();
665
666 self.to_edifact(&msg_stammdaten, &tx_stammdaten, fv, variant, pid)
667 }
668
669 /// Parse an EDIFACT interchange string into a typed PID interchange struct.
670 ///
671 /// Runs the full pipeline: tokenize → split messages → assemble → forward-map → deserialize.
672 /// The type parameters `M` and `T` are the message-level and transaction-level
673 /// stammdaten types from the generated PID module.
674 ///
675 /// # Example
676 ///
677 /// ```ignore
678 /// use bo4e_edifact_types::generated::fv2504::utilmd::pids::pid_55001::*;
679 ///
680 /// let interchange: Interchange<Pid55001MsgStammdaten, Pid55001TxStammdaten> =
681 /// mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", "55001")?;
682 ///
683 /// let tx = &interchange.nachrichten[0].transaktionen[0];
684 /// println!("Vorgang: {}", tx.prozessdaten.vorgang_id);
685 /// ```
686 ///
687 /// Mapping is lossy for content the assembler cannot place: segments the
688 /// PID's AHB does not cover, and segments whose group lacks its entry segment
689 /// (e.g. SG10 `CAV` without `CCI`). They have no BO4E representation and are
690 /// dropped. The conversion still succeeds, so that everything else in the
691 /// message is available; each dropped segment is logged as a `tracing`
692 /// warning. Use [`from_edifact_with_diagnostics`] to inspect them in code
693 /// (e.g. to reject such messages).
694 ///
695 /// [`from_edifact_with_diagnostics`]: Self::from_edifact_with_diagnostics
696 pub fn from_edifact<M, T>(
697 &self,
698 edifact: &str,
699 fv: &str,
700 variant: &str,
701 pid: &str,
702 ) -> Result<mig_bo4e::model::Interchange<M, T>, MapperError>
703 where
704 M: serde::de::DeserializeOwned,
705 T: serde::de::DeserializeOwned,
706 {
707 let (interchange, diagnostics) =
708 self.from_edifact_with_diagnostics(edifact, fv, variant, pid)?;
709 // This signature has no room for diagnostics, and dropped content must
710 // not go unnoticed (#103): log it for callers that don't ask for it.
711 for d in &diagnostics {
712 tracing::warn!(
713 fv,
714 variant,
715 pid,
716 kind = ?d.kind,
717 segment = %d.segment_id,
718 position = d.position,
719 "from_edifact: {}",
720 d.message
721 );
722 }
723 Ok(interchange)
724 }
725
726 /// [`from_edifact`], plus the structure diagnostics raised while assembling.
727 ///
728 /// A non-empty diagnostic list does not mean the conversion failed — it means
729 /// the BO4E result does not represent everything the EDIFACT carried. In
730 /// particular [`SkippedUnknownSegment`] marks a segment outside the PID's AHB
731 /// that the assembler advanced past, and [`OrphanedGroupSegment`] a segment
732 /// the MIG defines but whose group's entry segment is missing; in both cases
733 /// its content is absent from the result.
734 ///
735 /// [`from_edifact`]: Self::from_edifact
736 /// [`SkippedUnknownSegment`]: mig_assembly::StructureDiagnosticKind::SkippedUnknownSegment
737 /// [`OrphanedGroupSegment`]: mig_assembly::StructureDiagnosticKind::OrphanedGroupSegment
738 pub fn from_edifact_with_diagnostics<M, T>(
739 &self,
740 edifact: &str,
741 fv: &str,
742 variant: &str,
743 pid: &str,
744 ) -> Result<
745 (
746 mig_bo4e::model::Interchange<M, T>,
747 Vec<mig_assembly::StructureDiagnostic>,
748 ),
749 MapperError,
750 >
751 where
752 M: serde::de::DeserializeOwned,
753 T: serde::de::DeserializeOwned,
754 {
755 self.ensure_bundle_loaded(fv)?;
756 let bundles = self.bundles.lock().unwrap();
757 let bundle = bundles.get(fv).unwrap();
758 let vc = bundle
759 .variant(variant)
760 .ok_or_else(|| MapperError::VariantNotFound {
761 fv: fv.to_string(),
762 variant: variant.to_string(),
763 })?;
764
765 let tx_group = vc.tx_group(pid).ok_or_else(|| MapperError::PidNotFound {
766 fv: fv.to_string(),
767 variant: variant.to_string(),
768 pid: pid.to_string(),
769 })?;
770
771 let msg_engine = vc.msg_engine(pid);
772 let tx_engine = vc.tx_engine(pid).ok_or_else(|| MapperError::PidNotFound {
773 fv: fv.to_string(),
774 variant: variant.to_string(),
775 pid: pid.to_string(),
776 })?;
777
778 let filtered_mig = vc
779 .filtered_mig(pid)
780 .ok_or_else(|| MapperError::NoMigSchema {
781 fv: fv.to_string(),
782 variant: variant.to_string(),
783 })?;
784
785 // Tokenize → split → assemble. Same assembler config as the v2 `convert`
786 // route: `strict_code_matching` disambiguates merged sibling slots, and
787 // `skip_unknown_segments` keeps the cursor moving past AHB-foreign
788 // segments — without it the cursor stalls on the first one and the whole
789 // message tail is silently dropped from the BO4E result.
790 let svc = ConversionService::from_mig(filtered_mig);
791 let (chunks, trees, assembly_diagnostics) = svc
792 .convert_interchange_to_trees_with_diagnostics(
793 edifact,
794 mig_assembly::assembler::AssemblerConfig {
795 strict_code_matching: true,
796 skip_unknown_segments: true,
797 ..Default::default()
798 },
799 )?;
800
801 let tree = trees.first().ok_or_else(|| {
802 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
803 "No messages in interchange".to_string(),
804 ))
805 })?;
806
807 // Extract envelope metadata
808 let interchangedaten = mig_bo4e::model::extract_interchangedaten(&chunks.envelope);
809 let msg_chunk = chunks.messages.first().ok_or_else(|| {
810 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
811 "No message chunks".to_string(),
812 ))
813 })?;
814 let (unh_ref, nachrichten_typ) = mig_bo4e::model::extract_unh_fields(&msg_chunk.unh);
815 let nachrichtendaten = mig_bo4e::model::Nachrichtendaten {
816 unh_referenz: unh_ref,
817 nachrichten_typ,
818 nachricht: Default::default(),
819 };
820
821 // Forward-map to typed interchange
822 let interchange = MappingEngine::map_interchange_typed::<M, T>(
823 &msg_engine,
824 &tx_engine,
825 tree,
826 tx_group,
827 true,
828 nachrichtendaten,
829 interchangedaten,
830 )
831 .map_err(|e| MapperError::Serialization(e.to_string()))?;
832
833 Ok((interchange, assembly_diagnostics))
834 }
835
836 /// Detect the PID (Pruefidentifikator) from a raw EDIFACT interchange.
837 ///
838 /// Tokenizes the input, splits into messages, and extracts the PID from the
839 /// first message using the RFF+Z13 segment (primary) or BGM+STS fallback.
840 ///
841 /// This enables inbound message processing where the PID is not known upfront:
842 ///
843 /// ```ignore
844 /// let pid = mapper.detect_pid(edifact_str)?;
845 /// let interchange: MyType = mapper.from_edifact(edifact_str, "FV2504", "UTILMD_Strom", &pid)?;
846 /// ```
847 pub fn detect_pid(&self, edifact: &str) -> Result<String, MapperError> {
848 let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
849 let chunks = mig_assembly::split_messages(segments)?;
850 let msg_chunk = chunks.messages.first().ok_or_else(|| {
851 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
852 "No messages found in EDIFACT content".to_string(),
853 ))
854 })?;
855 let msg_segments = msg_chunk.message_segments();
856 mig_assembly::pid_detect::detect_pid(&msg_segments).map_err(MapperError::Assembly)
857 }
858
859 /// Validate raw EDIFACT against its AHB rules.
860 ///
861 /// This is the same pipeline as the v2 API's `POST /api/v2/validate`
862 /// (`run_validation`) — both call [`validate_edifact_message`] — exposed here
863 /// as a library call so consumers (e.g. mako.hive) get full raw-EDIFACT
864 /// validation without running the API server. Detects the PID, resolves the
865 /// owning variant + its pre-built [`AhbWorkflow`] from the loaded bundle,
866 /// assembles the message, and runs the shared validation core.
867 ///
868 /// Requires the bundle for `fv` to carry `pid_ahb_workflows` (baked in at
869 /// compile-mappings). Returns [`MapperError::PidNotFound`] if no loaded variant
870 /// has a workflow for the detected PID.
871 ///
872 /// [`validate_edifact_message`]: automapper_validation::validate_edifact_message
873 /// [`AhbWorkflow`]: automapper_validation::AhbWorkflow
874 pub fn validate_edifact(
875 &self,
876 edifact: &str,
877 fv: &str,
878 level: automapper_validation::ValidationLevel,
879 ) -> Result<automapper_validation::ValidationReport, MapperError> {
880 self.validate_edifact_inner(edifact, fv, None, level)
881 }
882
883 /// [`validate_edifact`], but validating against a PID the caller already knows.
884 ///
885 /// Use this when the PID comes from somewhere other than the message — a form,
886 /// a route, a job definition. It skips PID detection, which only works for
887 /// message types that carry the Prüfidentifikator in `RFF+Z13` (UTILMD); for
888 /// ORDERS, MSCONS, IFTSTA and the rest, detection cannot recover a PID that the
889 /// caller already has.
890 ///
891 /// [`validate_edifact`]: Self::validate_edifact
892 pub fn validate_edifact_for_pid(
893 &self,
894 edifact: &str,
895 fv: &str,
896 variant: &str,
897 pid: &str,
898 level: automapper_validation::ValidationLevel,
899 ) -> Result<automapper_validation::ValidationReport, MapperError> {
900 self.validate_edifact_inner(edifact, fv, Some((variant, pid)), level)
901 }
902
903 fn validate_edifact_inner(
904 &self,
905 edifact: &str,
906 fv: &str,
907 known: Option<(&str, &str)>,
908 level: automapper_validation::ValidationLevel,
909 ) -> Result<automapper_validation::ValidationReport, MapperError> {
910 self.ensure_bundle_loaded(fv)?;
911 let bundles = self.bundles.lock().unwrap();
912 let bundle = bundles.get(fv).unwrap();
913
914 // Tokenize → split → first message (same as `detect_pid`).
915 let segments = mig_assembly::tokenize::parse_to_segments(edifact.as_bytes())?;
916 let chunks = mig_assembly::split_messages(segments)?;
917 let msg_chunk = chunks.messages.first().ok_or_else(|| {
918 MapperError::Assembly(mig_assembly::AssemblyError::ParseError(
919 "No messages found in EDIFACT content".to_string(),
920 ))
921 })?;
922
923 // Resolve the PID: detect it when the caller doesn't know it, and resolve
924 // the owning variant from the bundle. When the caller does know both (the
925 // `validate_bo4e` path), take them as given — detection only works for
926 // message types that carry the PID in RFF+Z13 (UTILMD), so re-deriving a
927 // PID the caller already supplied would fail on ORDERS, MSCONS, IFTSTA, …
928 let (pid, variant, vc) = match known {
929 Some((variant, pid)) => {
930 let vc = bundle
931 .variant(variant)
932 .ok_or_else(|| MapperError::VariantNotFound {
933 fv: fv.to_string(),
934 variant: variant.to_string(),
935 })?;
936 (pid.to_string(), variant.to_string(), vc)
937 }
938 None => {
939 let pid = mig_assembly::pid_detect::detect_pid(&msg_chunk.message_segments())
940 .map_err(MapperError::Assembly)?;
941 let pid_key = format!("pid_{pid}");
942 let (variant, vc) = bundle
943 .variants
944 .iter()
945 .find(|(_, vc)| vc.pid_ahb_workflows.contains_key(&pid_key))
946 .ok_or_else(|| MapperError::PidNotFound {
947 fv: fv.to_string(),
948 variant: "?".to_string(),
949 pid: pid.clone(),
950 })?;
951 (pid, variant.clone(), vc)
952 }
953 };
954 let pid_key = format!("pid_{pid}");
955
956 let workflow =
957 vc.pid_ahb_workflows
958 .get(&pid_key)
959 .ok_or_else(|| MapperError::PidNotFound {
960 fv: fv.to_string(),
961 variant: variant.clone(),
962 pid: pid.clone(),
963 })?;
964 let filtered_mig = vc
965 .filtered_mig(&pid)
966 .ok_or_else(|| MapperError::NoMigSchema {
967 fv: fv.to_string(),
968 variant: variant.clone(),
969 })?;
970
971 // Segments the validator sees: this message's body for the filtered MIG,
972 // plus the interchange UNZ when the MIG covers it (e.g. MSCONS).
973 let mut all_segments = msg_chunk.segments_for_mig(&filtered_mig);
974 if filtered_mig.segments.iter().any(|s| s.id == "UNZ") {
975 if let Some(unz) = &chunks.unz {
976 all_segments.push(unz.clone());
977 }
978 }
979
980 // Same evaluator resolution + fallback the v2 route uses. The explicit
981 // target type lets each arm coerce (Box<dyn> → Arc<dyn>; Arc<Concrete> →
982 // Arc<dyn> unsize) — a `.map(Arc::from)` chain can't infer that.
983 let evaluator: std::sync::Arc<dyn automapper_validation::ConditionEvaluator> =
984 match crate::evaluator_factory::create_evaluator(&variant, fv) {
985 Some(boxed) => std::sync::Arc::from(boxed),
986 None => std::sync::Arc::new(
987 automapper_validation::UtilmdStromConditionEvaluatorFV2504::default(),
988 ),
989 };
990 let external = automapper_validation::eval::NoOpExternalProvider;
991
992 let mut report = automapper_validation::validate_edifact_message(
993 &all_segments,
994 &filtered_mig,
995 workflow,
996 evaluator,
997 &external,
998 level,
999 );
1000
1001 // Enrich findings with BO4E field paths so consumers can map the
1002 // segment-path findings back to the BO4E form (same enrichment the v2
1003 // `validate-bo4e` route applies). Sourced entirely from the bundle: the
1004 // combined mapping defs, the PID-filtered MIG, and a reverse resolver
1005 // built from the full MIG — no generated schema files needed.
1006 if let (Some(mig), Some(defs)) = (vc.mig_schema.as_ref(), vc.combined_defs.get(&pid_key)) {
1007 let reverse = mig_bo4e::path_resolver::ReversePathResolver::from_mig(mig);
1008 let field_index =
1009 mig_bo4e::Bo4eFieldIndex::build_with_resolver(defs, &filtered_mig, &reverse);
1010 report.enrich_bo4e_paths(|path, hint| field_index.resolve(path, hint));
1011 }
1012
1013 Ok(report)
1014 }
1015
1016 /// Validate BO4E JSON against the AHB rules of its Prüfidentifikator.
1017 ///
1018 /// This is [`validate_edifact`] with a reverse-mapping front end: the BO4E
1019 /// input is rendered to a complete EDIFACT interchange
1020 /// ([`to_edifact_interchange`]) and that interchange is validated. Because it
1021 /// is literally the same call, the findings are the ones the EDIFACT
1022 /// validation reports for the message this BO4E describes — including the
1023 /// `bo4e_path` enrichment that points each finding back at the BO4E field it
1024 /// came from. Callers working in BO4E (forms, assistants) therefore do not
1025 /// need their own EDIFACT-path-to-BO4E-path translation.
1026 ///
1027 /// `envelope` fills UNB/UNZ. Pass `None` unless the message type's MIG covers
1028 /// the interchange envelope (e.g. MSCONS) — for the others the envelope is
1029 /// outside the AHB and a neutral placeholder is used.
1030 ///
1031 /// Two classes of finding cannot appear here, because the BO4E input has no
1032 /// counterpart for them: the UNT segment-count check (the trailer is
1033 /// regenerated) and skipped-unknown-segment diagnostics (segments outside the
1034 /// AHB have no BO4E representation).
1035 ///
1036 /// [`validate_edifact`]: Self::validate_edifact
1037 /// [`to_edifact_interchange`]: Self::to_edifact_interchange
1038 pub fn validate_bo4e(
1039 &self,
1040 msg_stammdaten: &serde_json::Value,
1041 tx_stammdaten: &[serde_json::Value],
1042 fv: &str,
1043 variant: &str,
1044 pid: &str,
1045 envelope: Option<&InterchangeEnvelope>,
1046 level: automapper_validation::ValidationLevel,
1047 ) -> Result<automapper_validation::ValidationReport, MapperError> {
1048 let placeholder;
1049 let envelope = match envelope {
1050 Some(e) => e,
1051 None => {
1052 placeholder = InterchangeEnvelope {
1053 sender: EdifactParty::bdew("9900000000001"),
1054 receiver: EdifactParty::bdew("9900000000002"),
1055 interchange_ref: "1".to_string(),
1056 };
1057 &placeholder
1058 }
1059 };
1060
1061 // Rendered without the entry-segment check `to_edifact_interchange`
1062 // applies: a group missing its entry segment is exactly the kind of
1063 // defect validation exists to report (as missing-field and structure
1064 // findings), so it must not abort the validation.
1065 let edifact = self.render_interchange(
1066 envelope,
1067 &[InterchangeMessage {
1068 message_ref: "1".to_string(),
1069 msg_stammdaten: msg_stammdaten.clone(),
1070 tx_stammdaten: tx_stammdaten.to_vec(),
1071 fv: fv.to_string(),
1072 variant: variant.to_string(),
1073 pid: pid.to_string(),
1074 }],
1075 EntrySegmentCheck::Render,
1076 &EnvelopeOptions::default(),
1077 )?;
1078
1079 // The PID is given, not detected: for every message type but UTILMD the
1080 // rendered EDIFACT carries no RFF+Z13 to detect it from.
1081 self.validate_edifact_for_pid(&edifact, fv, variant, pid, level)
1082 }
1083
1084 /// Get the UNH association code for a variant (e.g., `"S2.1"`, `"2.4c"`).
1085 ///
1086 /// This is the version string from the MIG schema, used as the last component
1087 /// of the UNH S009 composite: `UTILMD:D:11A:UN:S2.1`.
1088 ///
1089 /// # Example
1090 /// ```ignore
1091 /// let code = mapper.association_code("FV2604", "UTILMD_Strom")?;
1092 /// assert_eq!(code, "S2.1");
1093 /// ```
1094 pub fn association_code(&self, fv: &str, variant: &str) -> Result<String, MapperError> {
1095 let meta = self.message_metadata(fv, variant)?;
1096 Ok(meta.association_code)
1097 }
1098
1099 /// Get full message metadata for a variant, including the UNH S009 components.
1100 ///
1101 /// Returns the message type, UN/EDIFACT release code, and association code
1102 /// needed to construct UNH segments.
1103 pub fn message_metadata(
1104 &self,
1105 fv: &str,
1106 variant: &str,
1107 ) -> Result<MessageMetadata, MapperError> {
1108 self.ensure_bundle_loaded(fv)?;
1109 let bundles = self.bundles.lock().unwrap();
1110 let bundle = bundles.get(fv).unwrap();
1111 let vc = bundle
1112 .variant(variant)
1113 .ok_or_else(|| MapperError::VariantNotFound {
1114 fv: fv.to_string(),
1115 variant: variant.to_string(),
1116 })?;
1117 let mig = vc
1118 .mig_schema
1119 .as_ref()
1120 .ok_or_else(|| MapperError::NoMigSchema {
1121 fv: fv.to_string(),
1122 variant: variant.to_string(),
1123 })?;
1124 Ok(MessageMetadata {
1125 message_type: mig.message_type.clone(),
1126 release: release_code_for_message_type(&mig.message_type),
1127 association_code: mig.version.clone(),
1128 })
1129 }
1130
1131 /// Convert BO4E JSON to a complete EDIFACT interchange with envelope segments.
1132 ///
1133 /// Produces a full interchange including UNA, UNB, UNH, message body, UNT, and UNZ.
1134 ///
1135 /// # The envelope is regenerated, not reproduced
1136 ///
1137 /// This always emits a `UNA` service string advice and stamps the `UNB`
1138 /// date and time from the clock, so a render is never byte-identical to the
1139 /// interchange it came from: an input carrying no `UNA` gains one, and its
1140 /// interchange date becomes today (issue #161). That is right for a
1141 /// re-send, and wrong for a caller checking that a conversion did not
1142 /// change the message.
1143 ///
1144 /// Two ways to check that instead:
1145 ///
1146 /// - compare message **bodies**, which
1147 /// [`to_edifact_nachricht`](Self::to_edifact_nachricht) renders without
1148 /// any envelope;
1149 /// - or reproduce the envelope with
1150 /// [`to_edifact_interchange_with`](Self::to_edifact_interchange_with) and
1151 /// [`EnvelopeOptions`], which take the `UNA` decision and the `UNB` date
1152 /// and time from the caller.
1153 ///
1154 /// Neither reproduces non-default delimiters: the whole render uses
1155 /// [`EdifactDelimiters::default`](edifact_primitives::EdifactDelimiters::default).
1156 ///
1157 /// # Example
1158 /// ```ignore
1159 /// let edifact = mapper.to_edifact_interchange(
1160 /// &InterchangeEnvelope {
1161 /// sender: EdifactParty::bdew("9900000000003"),
1162 /// receiver: EdifactParty::bdew("9900000000001"),
1163 /// interchange_ref: "REF001".to_string(),
1164 /// },
1165 /// &[InterchangeMessage {
1166 /// message_ref: "MSG001".to_string(),
1167 /// msg_stammdaten: serde_json::json!({"marktteilnehmer": []}),
1168 /// tx_stammdaten: vec![serde_json::json!({"prozessdaten": {"pruefidentifikator": "55001"}})],
1169 /// fv: "FV2604".to_string(),
1170 /// variant: "UTILMD_Strom".to_string(),
1171 /// pid: "55001".to_string(),
1172 /// }],
1173 /// )?;
1174 /// assert!(edifact.starts_with("UNA:+.? '"));
1175 /// ```
1176 ///
1177 /// # Errors
1178 ///
1179 /// Fails like [`to_edifact`](Self::to_edifact), including
1180 /// [`MapperError::MissingGroupEntrySegment`] for a group that would be
1181 /// rendered without its entry segment.
1182 pub fn to_edifact_interchange(
1183 &self,
1184 envelope: &InterchangeEnvelope,
1185 messages: &[InterchangeMessage],
1186 ) -> Result<String, MapperError> {
1187 self.render_interchange(
1188 envelope,
1189 messages,
1190 EntrySegmentCheck::Refuse,
1191 &EnvelopeOptions::default(),
1192 )
1193 }
1194
1195 /// Like [`to_edifact_interchange`](Self::to_edifact_interchange), with
1196 /// control over how the envelope is built.
1197 ///
1198 /// The default regenerates it — a fresh `UNA` and a `UNB` timestamped from
1199 /// the clock — which is right for a re-send but means a render can never
1200 /// equal its input. [`EnvelopeOptions`] lets a caller that has the original
1201 /// ask for it back instead (issue #161).
1202 ///
1203 /// # Errors
1204 ///
1205 /// As [`to_edifact_interchange`](Self::to_edifact_interchange).
1206 pub fn to_edifact_interchange_with(
1207 &self,
1208 envelope: &InterchangeEnvelope,
1209 messages: &[InterchangeMessage],
1210 options: &EnvelopeOptions,
1211 ) -> Result<String, MapperError> {
1212 self.render_interchange(envelope, messages, EntrySegmentCheck::Refuse, options)
1213 }
1214
1215 fn render_interchange(
1216 &self,
1217 envelope: &InterchangeEnvelope,
1218 messages: &[InterchangeMessage],
1219 check: EntrySegmentCheck,
1220 options: &EnvelopeOptions,
1221 ) -> Result<String, MapperError> {
1222 let delimiters = edifact_primitives::EdifactDelimiters::default();
1223 let sep = delimiters.component as char;
1224 let elem = delimiters.element as char;
1225 let seg_term = delimiters.segment as char;
1226
1227 let mut output = String::new();
1228
1229 // UNA — Service string advice. Omitted on request: an input that
1230 // carried none should not gain one (issue #161).
1231 if options.emit_una {
1232 output.push_str(&format!(
1233 "UNA{}{}{}{}{}{}",
1234 sep, // component separator
1235 elem, // element separator
1236 delimiters.decimal as char, // decimal notation
1237 delimiters.release as char, // release/escape character
1238 ' ', // reserved (space)
1239 seg_term, // segment terminator
1240 ));
1241 }
1242
1243 // UNB — Interchange header. The caller's date and time when it has
1244 // them, the clock otherwise.
1245 //
1246 // Checked here rather than in the builder: `datum_zeit` returns `Self`
1247 // so it cannot fail without spoiling the chaining, and this is the only
1248 // place that knows both values are present. A width-and-digits check is
1249 // all that is possible and all that is needed — it cannot know whether
1250 // a date is the right one, but it catches the two mistakes that happen,
1251 // an ISO date and a human-formatted time.
1252 check_unb_field("datum", "yymmdd", 6, options.datum.as_deref())?;
1253 check_unb_field("zeit", "hhmm", 4, options.zeit.as_deref())?;
1254
1255 let now = chrono::Utc::now();
1256 let date_str = options
1257 .datum
1258 .clone()
1259 .unwrap_or_else(|| now.format("%y%m%d").to_string());
1260 let time_str = options
1261 .zeit
1262 .clone()
1263 .unwrap_or_else(|| now.format("%H%M").to_string());
1264 let sender = &envelope.sender;
1265 let receiver = &envelope.receiver;
1266 let interchange_ref = &envelope.interchange_ref;
1267 output.push_str(&format!(
1268 "UNB{elem}UNOC{sep}3{elem}{sid}{sep}{sq}{elem}{rid}{sep}{rq}{elem}{date_str}{sep}{time_str}{elem}{interchange_ref}{seg_term}",
1269 sid = sender.id,
1270 sq = sender.qualifier,
1271 rid = receiver.id,
1272 rq = receiver.qualifier,
1273 ));
1274
1275 let mut message_count = 0u32;
1276
1277 for msg in messages {
1278 let meta = self.message_metadata(&msg.fv, &msg.variant)?;
1279
1280 // Generate body segments
1281 let body = self.render_message_body(
1282 &msg.msg_stammdaten,
1283 &msg.tx_stammdaten,
1284 &msg.fv,
1285 &msg.variant,
1286 &msg.pid,
1287 check,
1288 )?;
1289
1290 // Count segments in body (split by segment terminator, filter empty)
1291 let body_seg_count = body
1292 .split(seg_term)
1293 .filter(|s: &&str| !s.is_empty())
1294 .count();
1295 // UNH + body segments + UNT = total segment count
1296 let segment_count = body_seg_count + 2;
1297
1298 // UNH — Message header
1299 output.push_str(&format!(
1300 "UNH{elem}{ref}{elem}{msg_type}{sep}D{sep}{release}{sep}UN{sep}{assoc}{seg_term}",
1301 ref = msg.message_ref,
1302 msg_type = meta.message_type,
1303 release = meta.release,
1304 assoc = meta.association_code,
1305 ));
1306
1307 // Body segments
1308 output.push_str(&body);
1309
1310 // UNT — Message trailer
1311 output.push_str(&format!(
1312 "UNT{elem}{segment_count}{elem}{ref}{seg_term}",
1313 ref = msg.message_ref,
1314 ));
1315
1316 message_count += 1;
1317 }
1318
1319 // UNZ — Interchange trailer
1320 output.push_str(&format!(
1321 "UNZ{elem}{message_count}{elem}{interchange_ref}{seg_term}",
1322 ));
1323
1324 Ok(output)
1325 }
1326
1327 /// List all format versions currently loaded in memory.
1328 pub fn loaded_format_versions(&self) -> Vec<String> {
1329 self.bundles.lock().unwrap().keys().cloned().collect()
1330 }
1331
1332 /// List all variants available in a format version's bundle.
1333 ///
1334 /// Loads the bundle if not already loaded.
1335 pub fn variants(&self, fv: &str) -> Result<Vec<String>, MapperError> {
1336 self.ensure_bundle_loaded(fv)?;
1337 let bundles = self.bundles.lock().unwrap();
1338 let bundle = bundles.get(fv).unwrap();
1339 Ok(bundle.variants.keys().cloned().collect())
1340 }
1341}
1342
1343/// Metadata about a message type needed for constructing UNH segments.
1344#[derive(Debug, Clone)]
1345pub struct MessageMetadata {
1346 /// EDIFACT message type (e.g., `"UTILMD"`, `"MSCONS"`).
1347 pub message_type: String,
1348 /// UN/EDIFACT directory release code (e.g., `"11A"`, `"04B"`).
1349 pub release: String,
1350 /// Association-assigned code / MIG version (e.g., `"S2.1"`, `"2.4c"`).
1351 pub association_code: String,
1352}
1353
1354/// Envelope parameters for [`Mapper::to_edifact_interchange`].
1355#[derive(Debug, Clone)]
1356pub struct InterchangeEnvelope {
1357 /// Sender party (UNB S002).
1358 pub sender: EdifactParty,
1359 /// Receiver party (UNB S003).
1360 pub receiver: EdifactParty,
1361 /// Unique interchange reference (UNB 0020 / UNZ 0020).
1362 pub interchange_ref: String,
1363}
1364
1365/// Reject an `UNB` date or time that is not `digits` digits.
1366///
1367/// `None` means the caller did not supply one and the clock is used, which is
1368/// always well formed.
1369fn check_unb_field(
1370 field: &'static str,
1371 expected: &'static str,
1372 digits: usize,
1373 value: Option<&str>,
1374) -> Result<(), MapperError> {
1375 let Some(value) = value else {
1376 return Ok(());
1377 };
1378 if value.len() == digits && value.bytes().all(|b| b.is_ascii_digit()) {
1379 return Ok(());
1380 }
1381 Err(MapperError::MalformedEnvelopeDateTime {
1382 field,
1383 expected,
1384 digits,
1385 value: value.to_string(),
1386 })
1387}
1388
1389/// How [`Mapper::to_edifact_interchange_with`] builds the interchange envelope.
1390///
1391/// The default is to **regenerate**: emit a `UNA` service string advice and
1392/// stamp the `UNB` date and time from the clock. That is right for a re-send,
1393/// and it is what [`Mapper::to_edifact_interchange`] does.
1394///
1395/// It is wrong for a caller comparing a render against its input, because the
1396/// two differences are not about the message (issue #161). Such a caller has
1397/// the original — the forward direction hands it back as `Interchangedaten` —
1398/// and can ask for it here.
1399///
1400/// ```ignore
1401/// let options = EnvelopeOptions::default()
1402/// .emit_una(false)
1403/// .datum_zeit_from(&interchange.interchangedaten);
1404/// ```
1405///
1406/// # What this cannot reproduce
1407///
1408/// Non-default delimiters. The whole render — envelope and body alike — uses
1409/// [`EdifactDelimiters::default`], so an input whose `UNA` declared other
1410/// delimiters cannot be reproduced, and `emit_una(true)` always advertises the
1411/// defaults. Suppressing the `UNA` is honest about that; claiming delimiters
1412/// the body does not honour would not be.
1413///
1414/// [`EdifactDelimiters::default`]: edifact_primitives::EdifactDelimiters::default
1415#[derive(Debug, Clone)]
1416pub struct EnvelopeOptions {
1417 emit_una: bool,
1418 datum: Option<String>,
1419 zeit: Option<String>,
1420}
1421
1422impl Default for EnvelopeOptions {
1423 fn default() -> Self {
1424 Self {
1425 emit_una: true,
1426 datum: None,
1427 zeit: None,
1428 }
1429 }
1430}
1431
1432impl EnvelopeOptions {
1433 /// Whether to emit the `UNA` service string advice. Default `true`.
1434 ///
1435 /// An input that carried no `UNA` gains one unless this is `false`.
1436 pub fn emit_una(mut self, emit: bool) -> Self {
1437 self.emit_una = emit;
1438 self
1439 }
1440
1441 /// Interchange date (`yymmdd`) and time (`hhmm`) for `UNB`, instead of the
1442 /// clock.
1443 ///
1444 /// Both go into the header verbatim. A value that is not the right number
1445 /// of digits is refused when the interchange is rendered — with
1446 /// [`MapperError::MalformedEnvelopeDateTime`], not silently — because `UNB`
1447 /// is the segment whose defects surface at the receiving gateway rather
1448 /// than anywhere the sender looks.
1449 pub fn datum_zeit(mut self, datum: impl Into<String>, zeit: impl Into<String>) -> Self {
1450 self.datum = Some(datum.into());
1451 self.zeit = Some(zeit.into());
1452 self
1453 }
1454
1455 /// Take the `UNB` date and time from the `Interchangedaten` the forward
1456 /// direction produced. Fields it does not carry are left to the clock.
1457 pub fn datum_zeit_from(mut self, daten: &mig_bo4e::model::Interchangedaten) -> Self {
1458 self.datum = daten.datum.clone();
1459 self.zeit = daten.zeit.clone();
1460 self
1461 }
1462}
1463
1464/// An EDIFACT interchange party (sender or receiver) with codelist qualifier.
1465#[derive(Debug, Clone)]
1466pub struct EdifactParty {
1467 /// Party identification (e.g., MP-ID `"9900000000003"` or GLN `"4045458000000"`).
1468 pub id: String,
1469 /// Codelist qualifier: `"500"` = BDEW, `"14"` = GS1/EAN.
1470 pub qualifier: String,
1471}
1472
1473impl EdifactParty {
1474 /// Create a party with BDEW codelist qualifier (500).
1475 pub fn bdew(id: &str) -> Self {
1476 Self {
1477 id: id.to_string(),
1478 qualifier: "500".to_string(),
1479 }
1480 }
1481
1482 /// Create a party with GS1/EAN codelist qualifier (14).
1483 pub fn gs1(id: &str) -> Self {
1484 Self {
1485 id: id.to_string(),
1486 qualifier: "14".to_string(),
1487 }
1488 }
1489}
1490
1491/// A single message to include in an interchange built by
1492/// [`Mapper::to_edifact_interchange`].
1493#[derive(Debug, Clone)]
1494pub struct InterchangeMessage {
1495 /// Unique message reference number (used in UNH/UNT).
1496 pub message_ref: String,
1497 /// Message-level stammdaten (e.g., marktteilnehmer).
1498 pub msg_stammdaten: serde_json::Value,
1499 /// Transaction-level stammdaten (one per transaction).
1500 pub tx_stammdaten: Vec<serde_json::Value>,
1501 /// Format version (e.g., `"FV2604"`).
1502 pub fv: String,
1503 /// Message variant (e.g., `"UTILMD_Strom"`).
1504 pub variant: String,
1505 /// Pruefidentifikator (e.g., `"55001"`).
1506 pub pid: String,
1507}
1508
1509/// What rendering does with a group instance that lacks its MIG entry segment.
1510#[derive(Debug, Clone, Copy)]
1511enum EntrySegmentCheck {
1512 /// Fail with [`MapperError::MissingGroupEntrySegment`].
1513 Refuse,
1514 /// Render it anyway (for validation, which reports the defect).
1515 Render,
1516}
1517
1518/// Find the mapping definitions for a group that rendered without its entry
1519/// segment, for the error message: the BO4E entities they fill, and the BO4E
1520/// fields the entry segment is built from (the data the caller has to supply).
1521///
1522/// `source_path` comes from the filtered MIG, where the variant qualifier of a
1523/// group may be absent (a PID with a single variant, or an instance whose
1524/// variant is unknown because its entry segment is missing: `sg4.sg8.sg10`)
1525/// while definitions carry one (`sg4.sg8_z03.sg10`), or the other way round.
1526/// An unqualified part therefore matches any variant of the same group.
1527fn describe_entry_segment_mappings<'d>(
1528 definition_sets: impl IntoIterator<Item = &'d [mig_bo4e::definition::MappingDefinition]>,
1529 source_path: &str,
1530 entry_segment: &str,
1531) -> (Vec<String>, Vec<String>) {
1532 fn qualifies(unqualified: &str, qualified: &str) -> bool {
1533 !unqualified.contains('_')
1534 && qualified.len() > unqualified.len()
1535 && qualified.is_char_boundary(unqualified.len())
1536 && qualified[..unqualified.len()].eq_ignore_ascii_case(unqualified)
1537 && qualified.as_bytes()[unqualified.len()] == b'_'
1538 }
1539 fn part_matches(mig_part: &str, def_part: &str) -> bool {
1540 def_part.eq_ignore_ascii_case(mig_part)
1541 || qualifies(mig_part, def_part)
1542 || qualifies(def_part, mig_part)
1543 }
1544 let mig_parts: Vec<&str> = source_path.split('.').collect();
1545
1546 let mut entities: Vec<String> = Vec::new();
1547 let mut entry_fields: Vec<String> = Vec::new();
1548 for def in definition_sets.into_iter().flatten() {
1549 let Some(def_path) = def.meta.source_path.as_deref() else {
1550 continue;
1551 };
1552 let def_parts: Vec<&str> = def_path.split('.').collect();
1553 if def_parts.len() != mig_parts.len()
1554 || !mig_parts
1555 .iter()
1556 .zip(&def_parts)
1557 .all(|(m, d)| part_matches(m, d))
1558 {
1559 continue;
1560 }
1561 if !entities.contains(&def.meta.entity) {
1562 entities.push(def.meta.entity.clone());
1563 }
1564 for (path, mapping) in &def.fields {
1565 let tag = path
1566 .split(['.', '['])
1567 .next()
1568 .unwrap_or_default()
1569 .to_ascii_uppercase();
1570 let target = match mapping {
1571 mig_bo4e::definition::FieldMapping::Simple(t) => t.as_str(),
1572 mig_bo4e::definition::FieldMapping::Structured(f) => f.target.as_str(),
1573 mig_bo4e::definition::FieldMapping::Nested(_) => continue,
1574 };
1575 if tag == entry_segment && !target.is_empty() {
1576 let field = format!("{}.{}", def.meta.entity, target);
1577 if !entry_fields.contains(&field) {
1578 entry_fields.push(field);
1579 }
1580 }
1581 }
1582 }
1583 (entities, entry_fields)
1584}
1585
1586/// UN/EDIFACT directory release code for a message type.
1587///
1588/// These are stable per-message-type constants from the BDEW/DVGW specifications.
1589fn release_code_for_message_type(msg_type: &str) -> String {
1590 mig_bo4e::model::release_code_for_message_type(msg_type).to_string()
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595 use super::*;
1596 use std::path::Path;
1597
1598 fn data_dir() -> Option<std::path::PathBuf> {
1599 // Try dist/ first (pre-built data bundles), then cache/mappings/
1600 let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist");
1601 if dist.join("edifact-data-FV2504.bin").exists() {
1602 return Some(dist);
1603 }
1604 let cache = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cache/mappings");
1605 if cache.join("FV2504").exists() {
1606 return Some(cache);
1607 }
1608 eprintln!("Skipping test: no DataBundle files found");
1609 None
1610 }
1611
1612 #[test]
1613 fn test_to_edifact_produces_edifact_output() {
1614 let Some(data_dir) = data_dir() else {
1615 return;
1616 };
1617 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1618
1619 let msg_stammdaten = serde_json::json!({
1620 "marktteilnehmer": [{
1621 "marktrolle": "MS",
1622 "rollencodenummer": "9900123456789",
1623 "codepflegeCode": "293"
1624 }]
1625 });
1626 let tx_stammdaten = serde_json::json!({
1627 "prozessdaten": {
1628 "pruefidentifikator": "55001",
1629 "vorgangId": "ABC123",
1630 "transaktionsgrund": "E01"
1631 }
1632 });
1633
1634 let result = mapper.to_edifact(
1635 &msg_stammdaten,
1636 &[tx_stammdaten],
1637 "FV2504",
1638 "UTILMD_Strom",
1639 "55001",
1640 );
1641 assert!(result.is_ok(), "to_edifact failed: {:?}", result.err());
1642 let edifact = result.unwrap();
1643 assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1644 // Should produce NAD segment from marktteilnehmer
1645 assert!(edifact.contains("NAD"), "Should contain NAD segment");
1646 // Should produce IDE segment from prozessdaten
1647 assert!(edifact.contains("IDE"), "Should contain IDE segment");
1648 }
1649
1650 #[test]
1651 fn test_to_edifact_struct_produces_edifact_output() {
1652 let Some(data_dir) = data_dir() else {
1653 return;
1654 };
1655 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1656
1657 let nachricht = serde_json::json!({
1658 "stammdaten": {
1659 "marktteilnehmer": [{
1660 "marktrolle": "MS",
1661 "rollencodenummer": "9900123456789",
1662 "codepflegeCode": "293"
1663 }]
1664 },
1665 "transaktionen": [{
1666 "prozessdaten": {
1667 "pruefidentifikator": "55001",
1668 "vorgangId": "ABC123"
1669 }
1670 }]
1671 });
1672
1673 let result = mapper.to_edifact_struct(&nachricht, "FV2504", "UTILMD_Strom", "55001");
1674 assert!(
1675 result.is_ok(),
1676 "to_edifact_struct failed: {:?}",
1677 result.err()
1678 );
1679 let edifact = result.unwrap();
1680 assert!(!edifact.is_empty(), "EDIFACT output should not be empty");
1681 }
1682
1683 #[test]
1684 fn test_to_edifact_invalid_fv_returns_error() {
1685 let Some(data_dir) = data_dir() else {
1686 return;
1687 };
1688 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1689
1690 let result = mapper.to_edifact(
1691 &serde_json::json!({}),
1692 &[serde_json::json!({})],
1693 "FV9999",
1694 "UTILMD_Strom",
1695 "55001",
1696 );
1697 assert!(result.is_err());
1698 }
1699
1700 #[test]
1701 fn test_to_edifact_invalid_variant_returns_error() {
1702 let Some(data_dir) = data_dir() else {
1703 return;
1704 };
1705 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1706
1707 let result = mapper.to_edifact(
1708 &serde_json::json!({}),
1709 &[serde_json::json!({})],
1710 "FV2504",
1711 "NONEXISTENT",
1712 "55001",
1713 );
1714 assert!(result.is_err());
1715 }
1716
1717 #[test]
1718 fn test_to_edifact_invalid_pid_returns_error() {
1719 let Some(data_dir) = data_dir() else {
1720 return;
1721 };
1722 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1723
1724 let result = mapper.to_edifact(
1725 &serde_json::json!({}),
1726 &[serde_json::json!({})],
1727 "FV2504",
1728 "UTILMD_Strom",
1729 "99999",
1730 );
1731 assert!(result.is_err());
1732 }
1733
1734 #[test]
1735 fn test_association_code() {
1736 let Some(data_dir) = data_dir() else {
1737 return;
1738 };
1739 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1740
1741 let code = mapper.association_code("FV2504", "UTILMD_Strom").unwrap();
1742 assert_eq!(code, "S2.1");
1743
1744 let code = mapper.association_code("FV2504", "MSCONS").unwrap();
1745 assert_eq!(code, "2.4c");
1746 }
1747
1748 #[test]
1749 fn test_message_metadata() {
1750 let Some(data_dir) = data_dir() else {
1751 return;
1752 };
1753 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1754
1755 let meta = mapper.message_metadata("FV2504", "UTILMD_Strom").unwrap();
1756 assert_eq!(meta.message_type, "UTILMD");
1757 assert_eq!(meta.release, "11A");
1758 assert_eq!(meta.association_code, "S2.1");
1759 }
1760
1761 #[test]
1762 fn test_to_edifact_interchange() {
1763 let Some(data_dir) = data_dir() else {
1764 return;
1765 };
1766 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1767
1768 let result = mapper.to_edifact_interchange(
1769 &InterchangeEnvelope {
1770 sender: EdifactParty::bdew("9900000000003"),
1771 receiver: EdifactParty::bdew("9900000000001"),
1772 interchange_ref: "REF001".to_string(),
1773 },
1774 &[InterchangeMessage {
1775 message_ref: "MSG001".to_string(),
1776 msg_stammdaten: serde_json::json!({
1777 "marktteilnehmer": [{
1778 "marktrolle": "MS",
1779 "rollencodenummer": "9900123456789",
1780 "codepflegeCode": "293"
1781 }]
1782 }),
1783 tx_stammdaten: vec![serde_json::json!({
1784 "prozessdaten": {
1785 "pruefidentifikator": "55001",
1786 "vorgangId": "ABC123",
1787 "transaktionsgrund": "E01"
1788 }
1789 })],
1790 fv: "FV2504".to_string(),
1791 variant: "UTILMD_Strom".to_string(),
1792 pid: "55001".to_string(),
1793 }],
1794 );
1795 assert!(
1796 result.is_ok(),
1797 "to_edifact_interchange failed: {:?}",
1798 result.err()
1799 );
1800 let edifact = result.unwrap();
1801
1802 // Verify envelope structure
1803 assert!(edifact.starts_with("UNA:+.? '"), "Should start with UNA");
1804 assert!(
1805 edifact.contains("UNB+UNOC:3+9900000000003:500+9900000000001:500+"),
1806 "Should contain UNB with sender/receiver"
1807 );
1808 assert!(
1809 edifact.contains("UNH+MSG001+UTILMD:D:11A:UN:S2.1'"),
1810 "Should contain UNH with correct S009"
1811 );
1812 assert!(edifact.contains("NAD"), "Should contain body NAD segment");
1813 assert!(edifact.contains("UNT+"), "Should contain UNT");
1814 assert!(
1815 edifact.contains("+MSG001'"),
1816 "UNT should reference message ref"
1817 );
1818 assert!(
1819 edifact.contains("UNZ+1+REF001'"),
1820 "Should contain UNZ with count and ref"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_detect_pid_from_rff_z13() {
1826 let Some(data_dir) = data_dir() else {
1827 return;
1828 };
1829 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1830
1831 let edifact = "\
1832 UNB+UNOC:3+9978842000002:500+9900269000000:500+250331:1329+REF001'\
1833 UNH+MSG001+UTILMD:D:11A:UN:S2.1'\
1834 BGM+E01+DOC001'\
1835 DTM+137:202503311329?+00:303'\
1836 NAD+MS+9978842000002::293'\
1837 NAD+MR+9900269000000::293'\
1838 IDE+24+TX001'\
1839 DTM+92:202505312200?+00:303'\
1840 DTM+93:202512312300?+00:303'\
1841 STS+7++E01+ZW4+E03'\
1842 LOC+Z16+12345678900'\
1843 RFF+Z13:55001'\
1844 UNT+12+MSG001'\
1845 UNZ+1+REF001'";
1846
1847 let pid = mapper.detect_pid(edifact).unwrap();
1848 assert_eq!(pid, "55001");
1849 }
1850
1851 #[test]
1852 fn test_detect_pid_no_messages_returns_error() {
1853 let Some(data_dir) = data_dir() else {
1854 return;
1855 };
1856 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1857
1858 let edifact = "UNB+UNOC:3+SENDER:500+RECEIVER:500+250401:1200+REF'\
1859 UNZ+0+REF'";
1860 assert!(mapper.detect_pid(edifact).is_err());
1861 }
1862
1863 #[test]
1864 fn test_list_pids_returns_entries() {
1865 let Some(data_dir) = data_dir() else {
1866 return;
1867 };
1868 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir)).unwrap();
1869 let pids = mapper.list_pids().expect("list_pids should succeed");
1870 assert!(!pids.is_empty(), "should return at least one PID");
1871 assert!(
1872 pids.iter().any(|p| p.pid == "55001"),
1873 "should include PID 55001"
1874 );
1875 assert!(
1876 pids.iter().any(|p| p.fv == "FV2504"),
1877 "should include FV2504"
1878 );
1879 assert!(
1880 pids.iter().any(|p| p.variant == "UTILMD_Strom"),
1881 "should include UTILMD_Strom"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_pid_requirements_returns_requirements() {
1887 let Some(data_dir) = data_dir() else {
1888 return;
1889 };
1890 let mapper = Mapper::from_data_dir(DataDir::path(&data_dir).eager(&["FV2504"])).unwrap();
1891
1892 let req = mapper
1893 .pid_requirements("FV2504", "UTILMD_Strom", "55001")
1894 .expect("pid_requirements should succeed");
1895
1896 assert_eq!(req.pid, "55001");
1897 assert!(
1898 !req.entities.is_empty(),
1899 "55001 should have at least one entity"
1900 );
1901 assert!(
1902 req.entities.iter().any(|e| e.entity == "Prozessdaten"),
1903 "55001 should have a Prozessdaten entity"
1904 );
1905 }
1906}