rubo4e 0.5.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
//! 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>` for
//! transparent field access and `Into<T>` / `From<Validated<T>>` for ergonomic
//! unwrapping.
//!
//! Requires only the `validate` feature (not `versioned`).
//!
//! ```rust,ignore
//! use rubo4e::validation::Validated;
//! use rubo4e::v202607::Marktlokation;
//!
//! let melo: Marktlokation = /* ... */;
//! let validated = Validated::new(melo)?;  // Err(garde::Report) if invariants violated
//! println!("{:?}", validated.lokations_id); // Deref to &Marktlokation
//! let inner: Marktlokation = validated.into_inner();
//! ```
//!
//! ## 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
///
/// ```rust,ignore
/// # use rubo4e::validation::Validated;
/// # use rubo4e::v202607::Marktlokation;
/// let malo = Marktlokation::default();
/// match Validated::new(malo) {
///     Ok(v)  => println!("valid: {:?}", v.lokations_id),
///     Err(r) => eprintln!("invalid: {r}"),
/// }
/// ```
#[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`.
            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`.
            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.
            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.
            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 arithmetic checks:
            ///
            /// 1. If exactly two of `gesamtnetto`, `gesamtsteuer`, `gesamtbrutto` are
            ///    `Some`, all three must be present (partial amounts are not checkable).
            /// 2. When all three totals are present:
            ///    `gesamtnetto + gesamtsteuer == gesamtbrutto`
            /// 3. When `gesamtbrutto` and `zu_zahlen` are both present:
            ///    `gesamtbrutto - rabatt_netto - sum(vorauszahlungen) == zu_zahlen`
            ///
            /// The arithmetic checks are gated on the `decimal` feature; without it
            /// `Betrag.wert` is `Option<String>` and numeric comparison is unsafe.
            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) };

                    // Currency-mismatch guard — all monetary fields must use the same Waehrungscode.
                    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);

                    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",
                        ));
                    }

                    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})"
                            )));
                        }
                    }

                    // zu_zahlen = gesamtbrutto - rabatt_netto - sum(vorauszahlungen)
                    if let (Some(b), Some(z)) = (wert(&v.gesamtbrutto), wert(&v.zu_zahlen)) {
                        let rabatt = wert(&v.rabatt_netto).unwrap_or(Decimal::ZERO);
                        let vorauszahlungen: Decimal = v
                            .vorauszahlungen
                            .as_deref()
                            .unwrap_or_default()
                            .iter()
                            .filter_map(|p| p.betrag.as_ref().and_then(|b| b.wert))
                            .fold(Decimal::ZERO, |acc, v| acc + v);
                        let expected = b - rabatt - vorauszahlungen;
                        if expected != z {
                            return Err(garde::Error::new(format!(
                                "gesamtbrutto ({b}) - rabatt_netto ({rabatt}) \
                                 - vorauszahlungen ({vorauszahlungen}) = {expected}, \
                                 but zu_zahlen is {z}"
                            )));
                        }
                    }
                } // end #[cfg(feature = "decimal")]
                Ok(())
            }

            /// `Zeitraum` must encode exactly one of the three valid modes:
            ///
            /// 1. **Duration**: `dauer` is set (ISO 8601 duration string, e.g. `"P1DT"`)
            /// 2. **Date range**: at least `startdatum` or `enddatum` is set
            /// 3. **Time range**: at least `startuhrzeit` or `enduhrzeit` is set
            ///
            /// When both `startdatum` and `enddatum` are present, `startdatum` must
            /// be strictly before `enddatum` (only checked when `time` feature is active).
            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 invariant: only enforced when time feature provides
                // native OffsetDateTime comparison semantics.
                #[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 strictly before enddatum ({end})"
                        )));
                    }
                }

                Ok(())
            }

            /// Kostenposition arithmetic: `einzelpreis * menge == betrag_kostenposition.wert`
            /// when all three values are present.
            ///
            /// Gated on the `decimal` feature; without it the fields are `Option<String>`
            /// and numeric arithmetic is not available.
            pub fn validate_kostenposition_arithmetic(
                v: &Kostenposition,
                _: &(),
            ) -> Result<(), garde::Error> {
                #[cfg(feature = "decimal")]
                {
                    // einzelpreis and menge are now typed structs (Preis / Menge) whose
                    // `.wert` holds the numeric amount as a Decimal.  Extract it with
                    // `and_then` so we skip the arithmetic check when the sub-field is absent.
                    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 expected = (ep * m).round_dp(10);
                        let actual = b.round_dp(10);
                        if expected != actual {
                            return Err(garde::Error::new(format!(
                                "einzelpreis.wert ({ep}) * menge.wert ({m}) = {expected}, \
                                 but betrag_kostenposition.wert is {actual}"
                            )));
                        }
                    }
                } // 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,
}

/// Converts a [`garde::Report`] into an iterator of structured [`ValidationFailure`]s.
///
/// `garde::Report` only implements `Display` (one big string), making it hard to
/// handle individual failures programmatically.  This function flattens the report
/// into one `ValidationFailure` per field error so callers can:
/// - render structured API error responses
/// - log individual field names with key-value pairs
/// - build test assertions per field
///
/// # Example
/// ```rust,ignore
/// use rubo4e::validation::{Validated, report_errors};
/// use rubo4e::v202607::Marktlokation;
///
/// let malo = Marktlokation::default();
/// if let Err(report) = Validated::new(malo) {
///     for failure in report_errors(&report) {
///         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()
}