Skip to main content

ocpi_kit/convert/
mod.rs

1//! Carrying objects between OCPI versions, with an explicit account of what was lost.
2//!
3//! A hub that connects a 2.2.1 CPO to a 2.3.0 eMSP has to translate every object that crosses it.
4//! The translations are not symmetric: going forward is almost always total, going back means
5//! deciding what to do with the fields the older version does not have. Silently dropping them —
6//! which is what a hand-written `From` impl does — turns a hub into a data shredder that nobody
7//! notices until an invoice is wrong.
8//!
9//! So both directions return a [`Converted<T>`]: the object, plus a [`Lossy`] report naming every
10//! field that could not be carried, by JSON Pointer, with the reason.
11//!
12//! ```
13//! use ocpi_kit::convert::{Downgrade, Upgrade};
14//! use ocpi_kit::{v2_2_1, v2_3_0};
15//!
16//! // 2.2.1 → 2.3.0: `incl_vat` becomes a VAT tax line.
17//! let old = v2_2_1::Price::with_vat("5.00".parse().unwrap(), "5.50".parse().unwrap());
18//! let new: v2_3_0::Price = old.upgrade().expect_lossless();
19//! assert_eq!(new.taxes.len(), 1);
20//! assert_eq!(new.after_taxes().to_string(), "5.50");
21//!
22//! // 2.3.0 → 2.2.1: several named taxes collapse into one `incl_vat`, and that is reported.
23//! let mut multi = v2_3_0::Price::new("5.00".parse().unwrap());
24//! multi.taxes.push(v2_3_0::TaxAmount::new("GST", None, "0.25".parse().unwrap()).unwrap());
25//! multi.taxes.push(v2_3_0::TaxAmount::new("QST", None, "0.50".parse().unwrap()).unwrap());
26//! let back = multi.downgrade();
27//! let old: v2_2_1::Price = back.value;
28//! assert_eq!(old.incl_vat.unwrap().to_string(), "5.75");
29//! assert!(!back.lossy.is_empty(), "the tax names did not survive");
30//! ```
31//!
32//! # What the direction of a conversion means
33//!
34//! * [`Upgrade`] goes to a **newer** version. Where the newer version added a required field, the
35//!   default is chosen from the older version's semantics and documented on the impl — for
36//!   example a 2.2.1 `Tariff` becomes a 2.3.0 one with `tax_included: NO`, because a 2.2.1
37//!   `PriceComponent.price` is *"Price per unit (excl. VAT)"* by definition.
38//! * [`Downgrade`] goes to an **older** version, and is where losses accumulate.
39//!
40//! # Enum values survive both directions
41//!
42//! Because the enums OCPI 2.3.0 opened are decoded leniently in 2.2.1 too
43//! ([`ocpi_lenient_enum!`](crate::ocpi_lenient_enum)), a 2.3.0 `ConnectorType::Mcs` downgrades to
44//! a 2.2.1 `ConnectorType::Custom("MCS")` — same string on the wire, no data lost — and upgrades
45//! straight back. Only *fields that do not exist* in the older version are ever dropped.
46
47use core::fmt;
48
49pub mod v2_2_1_v2_3_0;
50pub mod wire;
51
52/// One piece of information that a conversion could not carry.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct Loss {
55    /// JSON Pointer (RFC 6901) to the value in the **source** object.
56    pub pointer: String,
57    /// What happened to it, and why.
58    pub reason: String,
59}
60
61impl fmt::Display for Loss {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let at = if self.pointer.is_empty() { "/" } else { &self.pointer };
64        write!(f, "{at}: {}", self.reason)
65    }
66}
67
68/// Everything a conversion could not carry, in document order.
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
70pub struct Lossy(Vec<Loss>);
71
72impl Lossy {
73    /// An empty report: nothing was lost.
74    #[must_use]
75    pub fn none() -> Self {
76        Self::default()
77    }
78
79    /// Whether the conversion was lossless.
80    #[must_use]
81    pub fn is_empty(&self) -> bool {
82        self.0.is_empty()
83    }
84
85    /// How many pieces of information were lost.
86    #[must_use]
87    pub fn len(&self) -> usize {
88        self.0.len()
89    }
90
91    /// The losses, in document order.
92    #[must_use]
93    pub fn as_slice(&self) -> &[Loss] {
94        &self.0
95    }
96
97    /// The losses, in document order.
98    pub fn iter(&self) -> core::slice::Iter<'_, Loss> {
99        self.0.iter()
100    }
101
102    /// Records a loss.
103    pub fn record(&mut self, pointer: impl Into<String>, reason: impl Into<String>) {
104        self.0.push(Loss { pointer: pointer.into(), reason: reason.into() });
105    }
106
107    /// Merges another report in, prefixing each of its pointers with `prefix`.
108    ///
109    /// Used to lift the losses of a nested object into its parent's coordinates.
110    pub fn absorb(&mut self, prefix: &str, other: Self) {
111        for loss in other.0 {
112            self.0.push(Loss { pointer: format!("{prefix}{}", loss.pointer), reason: loss.reason });
113        }
114    }
115
116    /// A one-line summary suitable for an OCPI `status_message`.
117    ///
118    /// A hub can attach this to a forwarded response so the receiving party knows the object was
119    /// translated and what did not survive.
120    #[must_use]
121    pub fn to_status_message(&self) -> Option<String> {
122        if self.is_empty() {
123            return None;
124        }
125        Some(format!(
126            "version bridged with {} loss(es): {}",
127            self.0.len(),
128            self.0.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
129        ))
130    }
131}
132
133impl<'a> IntoIterator for &'a Lossy {
134    type Item = &'a Loss;
135    type IntoIter = core::slice::Iter<'a, Loss>;
136    fn into_iter(self) -> Self::IntoIter {
137        self.iter()
138    }
139}
140
141impl IntoIterator for Lossy {
142    type Item = Loss;
143    type IntoIter = std::vec::IntoIter<Loss>;
144    fn into_iter(self) -> Self::IntoIter {
145        self.0.into_iter()
146    }
147}
148
149impl fmt::Display for Lossy {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        for (i, loss) in self.0.iter().enumerate() {
152            if i > 0 {
153                f.write_str("; ")?;
154            }
155            write!(f, "{loss}")?;
156        }
157        Ok(())
158    }
159}
160
161/// The result of a version conversion: the object, and what it cost.
162#[derive(Clone, Debug, PartialEq)]
163pub struct Converted<T> {
164    /// The converted object.
165    pub value: T,
166    /// Everything that could not be carried across.
167    pub lossy: Lossy,
168}
169
170impl<T> Converted<T> {
171    /// A conversion that lost nothing.
172    #[must_use]
173    pub fn lossless(value: T) -> Self {
174        Self { value, lossy: Lossy::none() }
175    }
176
177    /// A conversion with a report.
178    #[must_use]
179    pub fn new(value: T, lossy: Lossy) -> Self {
180        Self { value, lossy }
181    }
182
183    /// Whether nothing was lost.
184    #[must_use]
185    pub fn is_lossless(&self) -> bool {
186        self.lossy.is_empty()
187    }
188
189    /// The object, discarding the report.
190    ///
191    /// Named to be conspicuous: reaching for this is how a hub silently loses data.
192    #[must_use]
193    pub fn ignore_losses(self) -> T {
194        self.value
195    }
196
197    /// The object, or the report if anything was lost.
198    ///
199    /// # Errors
200    ///
201    /// Returns the [`Lossy`] report when the conversion was not lossless.
202    pub fn into_lossless(self) -> Result<T, Lossy> {
203        if self.lossy.is_empty() { Ok(self.value) } else { Err(self.lossy) }
204    }
205
206    /// The object, panicking if anything was lost. For tests and examples.
207    ///
208    /// # Panics
209    ///
210    /// Panics when the conversion lost something.
211    #[must_use]
212    pub fn expect_lossless(self) -> T {
213        assert!(self.lossy.is_empty(), "conversion was not lossless: {}", self.lossy);
214        self.value
215    }
216
217    /// Applies `f` to the value, keeping the report.
218    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Converted<U> {
219        Converted { value: f(self.value), lossy: self.lossy }
220    }
221}
222
223impl<T> core::ops::Deref for Converted<T> {
224    type Target = T;
225    fn deref(&self) -> &T {
226        &self.value
227    }
228}
229
230/// Converts an object to a **newer** OCPI version.
231///
232/// Where the newer version introduced a required field, the impl documents the default it picks
233/// and the spec text that justifies it.
234pub trait Upgrade<T> {
235    /// Converts to the newer version.
236    fn upgrade(self) -> Converted<T>;
237}
238
239/// Converts an object to an **older** OCPI version.
240///
241/// This is where information goes missing; every impl reports what it dropped.
242pub trait Downgrade<T> {
243    /// Converts to the older version.
244    fn downgrade(self) -> Converted<T>;
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn losses_are_lifted_into_the_parents_coordinates() {
253        let mut child = Lossy::none();
254        child.record("/help_phone", "not present in OCPI 2.2.1");
255        let mut parent = Lossy::none();
256        parent.absorb("/evses/0", child);
257        assert_eq!(parent.as_slice()[0].pointer, "/evses/0/help_phone");
258    }
259
260    #[test]
261    fn a_lossless_conversion_unwraps_and_a_lossy_one_does_not() {
262        let clean: Converted<u8> = Converted::lossless(7);
263        assert!(clean.is_lossless());
264        assert_eq!(clean.into_lossless().unwrap(), 7);
265
266        let mut lossy = Lossy::none();
267        lossy.record("/x", "dropped");
268        let dirty = Converted::new(7u8, lossy);
269        assert!(dirty.clone().into_lossless().is_err());
270        assert_eq!(dirty.ignore_losses(), 7);
271    }
272
273    #[test]
274    fn a_report_renders_as_a_status_message() {
275        assert_eq!(Lossy::none().to_status_message(), None);
276        let mut lossy = Lossy::none();
277        lossy.record("/help_phone", "not present in OCPI 2.2.1");
278        let message = lossy.to_status_message().unwrap();
279        assert!(message.contains("1 loss(es)") && message.contains("/help_phone"), "{message}");
280    }
281}