rubo4e 0.10.0

Rust implementation of the BO4E energy-market data standard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
//! Cross-field business-rule validators for BO4E types, plus the [`Validated`](crate::validation::Validated) wrapper.
//!
//! ## `Validated<T>`
//!
//! [`Validated<T>`](crate::validation::Validated) is a zero-cost newtype wrapper that can only be
//! constructed by running the garde validation rules on `T`.  It implements
//! `Deref<Target = T>` and `AsRef<T>` for transparent field access, and
//! [`into_inner`](crate::validation::Validated::into_inner) to unwrap.
//!
//! A blanket `impl From<Validated<T>> for T` is deliberately absent: `T` is a type
//! parameter, so such an impl is uncovered and rejected by the orphan rule.
//! `into_inner()` is the unwrapping path.
//!
//! Requires only the `validate` feature (not `versioned`).
//!
//! ```
//! # #[cfg(feature = "versioned")] {
//! use rubo4e::validation::Validated;
//! use rubo4e::current::{Marktlokation, Adresse};
//!
//! // A Marktlokation must carry exactly one of the three address fields.
//! let malo = Marktlokation {
//!     lokationsadresse: Some(Adresse { ort: Some("Bremen".into()), ..Default::default() }),
//!     ..Default::default()
//! };
//!
//! let validated = Validated::new(malo).expect("exactly one address field is set");
//! assert!(validated.lokationsadresse.is_some());  // Deref to &Marktlokation
//! let inner: Marktlokation = validated.into_inner();
//!
//! // A Marktlokation with no address at all is rejected.
//! assert!(Validated::new(Marktlokation::default()).is_err());
//! # }
//! ```
//!
//! ## Cross-field validators
//!
//! Each function has the signature expected by [`garde`]:
//! ```text
//! fn validate_xxx(value: &T, context: &()) -> Result<(), garde::Error>
//! ```
//!
//! Validators are emitted via `#[garde(custom(...))]` on the generated structs.
//! Functions are only present when both `validate` and `versioned` features are active.
//!
//! ## Allocation behaviour
//!
//! Static error messages (e.g. "exactly one address field must be set") are stored
//! as `Cow::Borrowed(&'static str)` inside `garde::Error` — zero allocation on the
//! failure path.  Error messages that include runtime values (timestamps, decimal
//! amounts) use a single `format!` call on the failure path — unavoidable for
//! meaningful diagnostics.  The **happy path is always zero-allocation** for all
//! validators in this module.

/// A zero-cost wrapper around a value that has been checked against all garde validation
/// rules.
///
/// `Validated<T>` is the only way to get a value that is guaranteed to satisfy all
/// business-rule invariants declared on `T` via `#[derive(garde::Validate)]`.
///
/// # Construction
///
/// Use [`Validated::new`] to validate and wrap a value.  Unwrap with [`Validated::into_inner`]
/// or by dereferencing (`&*validated`).
///
/// # Examples
///
/// ```
/// # #[cfg(feature = "versioned")] {
/// use rubo4e::validation::Validated;
/// use rubo4e::current::Marktlokation;
///
/// // No address field set — violates the "exactly one" rule.
/// match Validated::new(Marktlokation::default()) {
///     Ok(v)  => panic!("unexpectedly valid: {:?}", v.marktlokations_id),
///     Err(r) => assert!(r.iter().count() > 0),
/// }
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct Validated<T>(T);

impl<T> Validated<T>
where
    T: garde::Validate,
    T::Context: Default,
{
    /// Validates `value` using its [`garde::Validate`] impl.
    ///
    /// Returns `Ok(Validated(value))` if all rules pass, or a [`garde::Report`]
    /// describing every failure.
    pub fn new(value: T) -> Result<Self, garde::Report> {
        value.validate()?;
        Ok(Self(value))
    }

    /// Consumes the wrapper and returns the inner (validated) value.
    #[inline]
    pub fn into_inner(self) -> T {
        self.0
    }
}

impl<T> std::ops::Deref for Validated<T> {
    type Target = T;
    #[inline]
    fn deref(&self) -> &T {
        &self.0
    }
}

impl<T> AsRef<T> for Validated<T> {
    #[inline]
    fn as_ref(&self) -> &T {
        &self.0
    }
}

#[cfg(feature = "serde")]
impl<T: serde::Serialize> serde::Serialize for Validated<T> {
    /// Serializes the inner (validated) value transparently.
    ///
    /// Consumers who receive a `Validated<T>` can serialize it without
    /// unwrapping, while retaining the type-level proof of validity.
    #[inline]
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        self.0.serialize(s)
    }
}

/// Stamps out a validation sub-module for a given schema version (e.g. `v202607`).
///
/// Each version gets its own `pub mod $ver { … }` so that future schema changes
/// (renamed fields, new rules) can diverge independently per version without
/// silently applying stale logic from an earlier release.
#[cfg(feature = "versioned")]
macro_rules! impl_validators {
    ($ver:ident) => {
        #[allow(missing_docs)]
        pub mod $ver {
            use crate::generated::$ver::*;

            /// Exactly one of `lokationsadresse`, `geoadresse`, or
            /// `katasterinformation` must be `Some`.
            ///
            /// BO4E states this rule but enforces it nowhere — BO4E-python
            /// carries it only as a source comment. Checked here only when you
            /// call `.validate()`; a violating payload still deserializes.
            pub fn validate_marktlokation(v: &Marktlokation, _: &()) -> Result<(), garde::Error> {
                let count = v.lokationsadresse.is_some() as usize
                    + v.geoadresse.is_some() as usize
                    + v.katasterinformation.is_some() as usize;
                if count == 1 {
                    Ok(())
                } else {
                    Err(garde::Error::new(
                        "exactly one address field must be set: \
                         lokationsadresse, geoadresse, or katasterinformation",
                    ))
                }
            }

            /// Exactly one of `messadresse`, `geoadresse`, or
            /// `katasterinformation` must be `Some`.
            ///
            /// Same provenance as [`validate_marktlokation`]: stated by BO4E,
            /// enforced by no reference implementation, opt-in here.
            pub fn validate_messlokation(v: &Messlokation, _: &()) -> Result<(), garde::Error> {
                let count = v.messadresse.is_some() as usize
                    + v.geoadresse.is_some() as usize
                    + v.katasterinformation.is_some() as usize;
                if count == 1 {
                    Ok(())
                } else {
                    Err(garde::Error::new(
                        "exactly one address field must be set: \
                         messadresse, geoadresse, or katasterinformation",
                    ))
                }
            }

            /// `vertragsbeginn` must be strictly before `vertragsende` when both
            /// are present.
            ///
            /// The ordering check is only performed when the `time` feature is active
            /// (fields are `time::OffsetDateTime`).  Without `time`, fields are `String`
            /// and lexicographic comparison is unsafe for partial ISO-8601 forms.
            // Without `time` the body compiles away and `v` goes unread.
            #[cfg_attr(not(feature = "time"), allow(unused_variables))]
            pub fn validate_vertrag_dates(v: &Vertrag, _: &()) -> Result<(), garde::Error> {
                #[cfg(feature = "time")]
                if let (Some(start), Some(end)) = (v.vertragsbeginn, v.vertragsende) {
                    if start >= end {
                        return Err(garde::Error::new(format!(
                            "vertragsbeginn ({start}) must be before vertragsende ({end})"
                        )));
                    }
                }
                Ok(())
            }

            /// `bilanzierungsbeginn` must be ≤ `bilanzierungsende` when both are
            /// present.
            ///
            /// The ordering check is only performed when the `time` feature is active.
            // Without `time` the body compiles away and `v` goes unread.
            #[cfg_attr(not(feature = "time"), allow(unused_variables))]
            pub fn validate_bilanzierung_dates(
                v: &Bilanzierung,
                _: &(),
            ) -> Result<(), garde::Error> {
                #[cfg(feature = "time")]
                if let (Some(start), Some(end)) = (v.bilanzierungsbeginn, v.bilanzierungsende) {
                    if start > end {
                        return Err(garde::Error::new(format!(
                            "bilanzierungsbeginn ({start}) must be ≤ bilanzierungsende ({end})"
                        )));
                    }
                }
                Ok(())
            }

            /// Invoice consistency checks, each traceable to a sentence in the
            /// BO4E schema:
            ///
            /// 1. All monetary fields must agree on a currency. Two `Betrag`s in
            ///    one invoice denominated differently cannot be summed, so any
            ///    downstream total would be meaningless.
            /// 2. `gesamtbrutto` is *"Die Summe aus Netto- und Steuerbetrag"* —
            ///    so `gesamtnetto + gesamtsteuer == gesamtbrutto` when all three
            ///    are present.
            /// 3. If exactly two of the three totals are present, the third is
            ///    derivable and its absence is a data-quality defect.
            /// 4. `steuerbetraege` is *"eine Liste mit Steuerbeträgen … die Summe
            ///    dieser Beträge ergibt den Wert für gesamtsteuer"* — so the
            ///    line-level tax amounts must sum to `gesamtsteuer`.
            ///
            /// # Not checked: `zuZahlen`
            ///
            /// Its schema description reads *"(gesamtbrutto - vorausbezahlt -
            /// rabattBrutto)"*, but v202607 has no `rabattBrutto` — only
            /// `rabattNetto`, a **net** discount, which cannot be subtracted from
            /// a gross total. The equation is not reconstructible from the
            /// payload, so nothing is asserted about it.
            ///
            /// The arithmetic is gated on the `decimal` feature; without it
            /// `Betrag.wert` is `Option<String>` and numeric comparison is unsafe.
            // Without `decimal` the body compiles away and `v` goes unread.
            #[cfg_attr(not(feature = "decimal"), allow(unused_variables))]
            pub fn validate_rechnung_arithmetic(v: &Rechnung, _: &()) -> Result<(), garde::Error> {
                #[cfg(feature = "decimal")]
                {
                    use rust_decimal::Decimal;

                    let wert =
                        |b: &Option<Betrag>| -> Option<Decimal> { b.as_ref().and_then(|b| b.wert) };

                    // 1. Currency-mismatch guard.
                    let waehrung = |b: &Option<Betrag>| b.as_ref().and_then(|b| b.waehrung);
                    let currencies = [
                        ("gesamtnetto", waehrung(&v.gesamtnetto)),
                        ("gesamtsteuer", waehrung(&v.gesamtsteuer)),
                        ("gesamtbrutto", waehrung(&v.gesamtbrutto)),
                        ("rabatt_netto", waehrung(&v.rabatt_netto)),
                        ("zu_zahlen", waehrung(&v.zu_zahlen)),
                    ];
                    let mut first_currency = None;
                    let mut first_field = "";
                    for (field, currency) in currencies {
                        if let Some(c) = currency {
                            match first_currency {
                                None => {
                                    first_currency = Some(c);
                                    first_field = field;
                                }
                                Some(fc) if fc != c => {
                                    return Err(garde::Error::new(format!(
                                        "currency mismatch: {first_field} uses {fc:?} \
                                         but {field} uses {c:?} — all Betrag fields in a \
                                         Rechnung must use the same Waehrungscode"
                                    )));
                                }
                                _ => {}
                            }
                        }
                    }

                    let netto = wert(&v.gesamtnetto);
                    let steuer = wert(&v.gesamtsteuer);
                    let brutto = wert(&v.gesamtbrutto);

                    // 3. Two of three present means the third was simply omitted.
                    let present_count = netto.is_some() as usize
                        + steuer.is_some() as usize
                        + brutto.is_some() as usize;
                    if present_count == 2 {
                        return Err(garde::Error::new(
                            "if any two invoice totals (gesamtnetto, gesamtsteuer, \
                             gesamtbrutto) are set, all three must be present",
                        ));
                    }

                    // 2. gesamtbrutto = gesamtnetto + gesamtsteuer.
                    if let (Some(n), Some(s), Some(b)) = (netto, steuer, brutto) {
                        if n + s != b {
                            return Err(garde::Error::new(format!(
                                "gesamtnetto ({n}) + gesamtsteuer ({s}) must equal \
                                 gesamtbrutto ({b})"
                            )));
                        }
                    }

                    // 4. sum(steuerbetraege[*].steuerwert) = gesamtsteuer.
                    //
                    // Only checked when every entry states a `steuerwert`: a list
                    // that omits one is incomplete rather than wrong, and summing
                    // the rest would report a mismatch that is not there.
                    if let (Some(entries), Some(total)) = (v.steuerbetraege.as_deref(), steuer) {
                        let all_stated =
                            !entries.is_empty() && entries.iter().all(|e| e.steuerwert.is_some());
                        if all_stated {
                            let summed = entries
                                .iter()
                                .filter_map(|e| e.steuerwert)
                                .try_fold(Decimal::ZERO, |acc, v| acc.checked_add(v));
                            match summed {
                                Some(sum) if sum != total => {
                                    return Err(garde::Error::new(format!(
                                        "steuerbetraege sum to {sum}, but gesamtsteuer \
                                         is {total}"
                                    )));
                                }
                                None => {
                                    return Err(garde::Error::new(
                                        "steuerbetraege overflow the Decimal range when summed",
                                    ));
                                }
                                _ => {}
                            }
                        }
                    }
                } // end #[cfg(feature = "decimal")]
                Ok(())
            }

            /// `Zeitraum` must encode **at least one** of the three modes:
            ///
            /// 1. **Duration**: `dauer` is set (ISO 8601 duration string, e.g. `"P1DT"`)
            /// 2. **Date range**: `startdatum` or `enddatum` is set
            /// 3. **Time range**: `startuhrzeit` or `enduhrzeit` is set
            ///
            /// Combinations are *not* rejected — the BO4E schema permits them, and a
            /// stricter "exactly one" rule would reject payloads that real senders
            /// produce (e.g. a date range annotated with an explicit duration).
            /// An entirely empty `Zeitraum` carries no information and is rejected.
            ///
            /// When both dates are present, `startdatum` must be **on or before**
            /// `enddatum` (only checked when `time` is active).
            ///
            /// # Why `<=` and not `<`
            ///
            /// BO4E declares both dates **inclusive**, and gives `'2025-01-01'` as
            /// the example for *both* of them: `startdatum == enddatum` is a valid
            /// one-day period, not an empty one. Requiring a strict `<` — as an
            /// earlier revision did, on the assumption that `enddatum` was
            /// exclusive — rejected every single-day Zeitraum in circulation.
            pub fn validate_zeitraum(v: &Zeitraum, _: &()) -> Result<(), garde::Error> {
                let has_duration = v.dauer.is_some();
                let has_date = v.startdatum.is_some() || v.enddatum.is_some();
                let has_time = v.startuhrzeit.is_some() || v.enduhrzeit.is_some();

                if !has_duration && !has_date && !has_time {
                    return Err(garde::Error::new(
                        "Zeitraum must have at least one of: dauer, startdatum/enddatum, \
                         or startuhrzeit/enduhrzeit",
                    ));
                }

                // Date ordering is only checked with `time`, where the fields are
                // `time::Date` and compare chronologically.  Without it they are
                // `String`, and a lexicographic comparison of partial ISO-8601
                // forms is not the same order.
                #[cfg(feature = "time")]
                if let (Some(start), Some(end)) = (v.startdatum, v.enddatum) {
                    if start > end {
                        return Err(garde::Error::new(format!(
                            "startdatum ({start}) must be on or before enddatum ({end}); \
                             both bounds are inclusive, so a one-day period has start == end"
                        )));
                    }
                }

                Ok(())
            }

            /// Kostenposition arithmetic: the line total must be the product of
            /// unit price and quantity.
            ///
            /// The schema describes `betragKostenposition` as the result of
            /// *"<Menge * Einzelpreis>"* **or** *"<Einzelpreis / (Anzahl Tage
            /// Jahr) * zeitmenge"*. Only the first form is checkable from the
            /// fields alone — the second needs the day count of the billing
            /// year, which the COM does not carry — so a position that states a
            /// `zeitmenge` is skipped rather than measured against the wrong
            /// formula.
            ///
            /// # Rounding
            ///
            /// The product is compared at the **scale of the stated amount**.
            /// A unit price of `0.2843 €/kWh` over `3333 kWh` is `947.5119`,
            /// which every invoice in circulation writes as `947.51`; demanding
            /// exact equality (or equality at ten decimal places, as an earlier
            /// revision did) rejects the entire real-world corpus.
            ///
            /// Gated on the `decimal` feature; without it the fields are
            /// `Option<String>` and numeric arithmetic is not available.
            // Without `decimal` the body compiles away and `v` goes unread.
            #[cfg_attr(not(feature = "decimal"), allow(unused_variables))]
            pub fn validate_kostenposition_arithmetic(
                v: &Kostenposition,
                _: &(),
            ) -> Result<(), garde::Error> {
                #[cfg(feature = "decimal")]
                {
                    use rust_decimal::Decimal;

                    // A time-proportional position uses the other formula.
                    if v.zeitmenge.is_some() {
                        return Ok(());
                    }
                    let betrag = v.betrag_kostenposition.as_ref().and_then(|b| b.wert);
                    let einzelpreis = v.einzelpreis.as_ref().and_then(|p| p.wert);
                    let menge = v.menge.as_ref().and_then(|m| m.wert);

                    if let (Some(ep), Some(m), Some(b)) = (einzelpreis, menge, betrag) {
                        let Some(product) = ep.checked_mul(m) else {
                            return Err(garde::Error::new(format!(
                                "einzelpreis ({ep}) * menge ({m}) overflows the Decimal range"
                            )));
                        };
                        // Accept the amount if it is *a* correct rounding of the
                        // product to its own scale — i.e. within half a unit in
                        // the last stated place.  Comparing against one rounding
                        // mode would reject the other: invoices round halves up,
                        // `Decimal::round_dp` rounds them to even.
                        let scale = b.scale();
                        let half_ulp = Decimal::new(5, scale.saturating_add(1));
                        if (product - b).abs() > half_ulp {
                            return Err(garde::Error::new(format!(
                                "einzelpreis.wert ({ep}) * menge.wert ({m}) = {product}, \
                                 which does not round to betrag_kostenposition.wert ({b}) \
                                 at its own scale of {scale} decimal place(s)"
                            )));
                        }
                    }
                } // end #[cfg(feature = "decimal")]
                Ok(())
            }
        }
    };
}

#[cfg(feature = "versioned")]
impl_validators!(v202607);

/// A single structured validation failure, extracted from a [`garde::Report`].
///
/// Use [`report_errors`] to convert a `garde::Report` into an iterator of these.
#[cfg(feature = "validate")]
#[cfg_attr(docsrs, doc(cfg(feature = "validate")))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationFailure {
    /// Dot-separated field path, e.g. `"betrag.wert"` or `"positionen[2].name"`.
    pub path: String,
    /// Human-readable error message for this field.
    pub message: String,
}

/// Flattens a [`garde::Report`] into one [`ValidationFailure`] per field error.
///
/// `garde::Report` only implements `Display` — one string with every failure in
/// it — which is unusable for anything but a log line. The flattened form lets
/// callers:
/// - render structured API error responses
/// - log individual field names with key-value pairs
/// - build test assertions per field
///
/// # Example
/// ```
/// # #[cfg(feature = "versioned")] {
/// use rubo4e::validation::{report_errors, Validated};
/// use rubo4e::current::Marktlokation;
///
/// let report = Validated::new(Marktlokation::default()).unwrap_err();
/// let failures = report_errors(&report);
/// assert!(!failures.is_empty());
/// for failure in &failures {
///     eprintln!("  {}: {}", failure.path, failure.message);
/// }
/// # }
/// ```
#[cfg(feature = "validate")]
#[cfg_attr(docsrs, doc(cfg(feature = "validate")))]
pub fn report_errors(report: &garde::Report) -> Vec<ValidationFailure> {
    report
        .iter()
        .map(|(path, error)| ValidationFailure {
            path: path.to_string(),
            message: error.to_string(),
        })
        .collect()
}