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}