Skip to main content

ocpi_kit/
version.rs

1//! Protocol versions, module identifiers and interface roles.
2//!
3//! These three enums are version-neutral on purpose: version *discovery* has to be able to name
4//! a version or a module that this crate does not implement — that is what the `/versions`
5//! endpoint is for — so all three are `OpenEnum`-shaped and keep values they do not know.
6
7use crate::ocpi_enum;
8use crate::ocpi_open_enum;
9
10ocpi_open_enum! {
11    /// A version of the OCPI protocol, as it appears in `/versions` and in version details.
12    ///
13    /// Listed as an `OpenEnum` so that discovery against a peer that speaks a version this crate
14    /// has never heard of — a future 3.0, or a version the peer invented — lists it and moves on
15    /// instead of failing. [`VersionNumber::is_supported`] says which ones this build can
16    /// actually talk.
17    ///
18    /// Spec: 2.3.0 §version_information_endpoint_versionnumber_enum
19    pub enum VersionNumber {
20        /// OCPI version 2.0. Not modelled by this crate; recognised so discovery can skip it.
21        V2_0 = "2.0",
22        /// OCPI version 2.1. **Deprecated by the spec**: *"do not use, use 2.1.1 instead"*.
23        V2_1 = "2.1",
24        /// OCPI version 2.1.1.
25        V2_1_1 = "2.1.1",
26        /// OCPI version 2.2. **Deprecated by the spec**: *"do not use, use 2.2.1 instead"*.
27        V2_2 = "2.2",
28        /// OCPI version 2.2.1. Still the most widely deployed version.
29        V2_2_1 = "2.2.1",
30        /// OCPI version 2.3.0. The canonical model of this crate.
31        V2_3_0 = "2.3.0",
32    }
33}
34
35impl VersionNumber {
36    /// Whether this build of `ocpi-kit` has a wire model for this version.
37    ///
38    /// Depends on the enabled cargo features.
39    #[must_use]
40    pub fn is_supported(&self) -> bool {
41        match self {
42            Self::V2_3_0 => cfg!(feature = "v2_3_0"),
43            Self::V2_2_1 => cfg!(feature = "v2_2_1"),
44            Self::V2_1_1 => cfg!(feature = "v2_1_1"),
45            Self::V2_0 | Self::V2_1 | Self::V2_2 | Self::Custom(_) => false,
46        }
47    }
48
49    /// Whether the specification marks this version as deprecated.
50    ///
51    /// Spec: 2.3.0 §version_information_endpoint_versionnumber_enum
52    #[must_use]
53    pub const fn is_deprecated(&self) -> bool {
54        matches!(self, Self::V2_1 | Self::V2_2)
55    }
56
57    /// Every version this build can talk, newest first.
58    ///
59    /// This is the preference order used when negotiating with a peer.
60    #[must_use]
61    #[allow(unused_mut, clippy::vec_init_then_push)]
62    pub fn supported() -> Vec<Self> {
63        let mut out = Vec::new();
64        #[cfg(feature = "v2_3_0")]
65        out.push(Self::V2_3_0);
66        #[cfg(feature = "v2_2_1")]
67        out.push(Self::V2_2_1);
68        #[cfg(feature = "v2_1_1")]
69        out.push(Self::V2_1_1);
70        out
71    }
72
73    /// Where this version sits in the release order, oldest first.
74    ///
75    /// A version this crate does not know ranks last. This is deliberately *not* the [`Ord`]
76    /// impl, which sorts by wire value so that `VersionNumber` behaves predictably as a map key
77    /// — and note that sorting by wire value is wrong for versions, since `"2.10" < "2.2"`
78    /// lexically. Use [`VersionNumber::cmp_by_release`] to order them.
79    #[must_use]
80    pub const fn release_rank(&self) -> u8 {
81        match self {
82            Self::V2_0 => 0,
83            Self::V2_1 => 1,
84            Self::V2_1_1 => 2,
85            Self::V2_2 => 3,
86            Self::V2_2_1 => 4,
87            Self::V2_3_0 => 5,
88            Self::Custom(_) => u8::MAX,
89        }
90    }
91
92    /// Orders two versions by release, oldest first; unknown versions sort last, by their text.
93    ///
94    /// ```
95    /// use ocpi_kit::VersionNumber;
96    /// let mut vs = vec![VersionNumber::V2_3_0, VersionNumber::V2_1_1, VersionNumber::V2_2_1];
97    /// vs.sort_by(VersionNumber::cmp_by_release);
98    /// assert_eq!(vs.first(), Some(&VersionNumber::V2_1_1));
99    /// ```
100    #[must_use]
101    pub fn cmp_by_release(&self, other: &Self) -> core::cmp::Ordering {
102        self.release_rank().cmp(&other.release_rank()).then_with(|| self.as_str().cmp(other.as_str()))
103    }
104
105    /// Whether the version uses the `OCPI-to-*`/`OCPI-from-*` message routing headers.
106    ///
107    /// Routing headers were introduced in OCPI 2.2; 2.1.1 and older have no such thing.
108    ///
109    /// Spec: 2.3.0 §transport_and_format_message_routing
110    #[must_use]
111    pub fn has_routing_headers(&self) -> bool {
112        matches!(self.release_rank(), 3..=5)
113    }
114
115    /// Whether the version splits `Credentials` into a list of `roles`.
116    ///
117    /// OCPI 2.1.1 puts `party_id`, `country_code` and `business_details` directly on the
118    /// credentials object; 2.2 and later moved them into `CredentialsRole` entries.
119    #[must_use]
120    pub fn has_credentials_roles(&self) -> bool {
121        matches!(self.release_rank(), 3..=5)
122    }
123}
124
125ocpi_enum! {
126    /// Which side of a module's data flow an endpoint implements.
127    ///
128    /// > *SENDER: Interface implemented by the owner of data, so the Receiver can Pull
129    /// > information from the data Sender/owner.*
130    /// >
131    /// > *RECEIVER: Interface implemented by the receiver of data, so the Sender/owner can Push
132    /// > information to the Receiver.*
133    ///
134    /// Spec: 2.3.0 §version_information_endpoint_interface_role_enum
135    pub enum InterfaceRole {
136        /// The data owner's interface, which the other party pulls from.
137        Sender = "SENDER",
138        /// The interface the data owner pushes to.
139        Receiver = "RECEIVER",
140    }
141}
142
143impl InterfaceRole {
144    /// The other side of the same module.
145    #[must_use]
146    pub const fn opposite(self) -> Self {
147        match self {
148            Self::Sender => Self::Receiver,
149            Self::Receiver => Self::Sender,
150        }
151    }
152}
153
154ocpi_open_enum! {
155    /// The identifier of an OCPI module, as used in `Endpoint.identifier`.
156    ///
157    /// > *Parties are allowed to create custom modules or customized versions of the existing
158    /// > modules. To do so, the ModuleID enum can be extended with additional custom moduleIDs.
159    /// > … It is advised to use a prefix (e.g. country-code + party-id) for any custom moduleID.*
160    ///
161    /// So `ModuleId::Custom("nltnm-tokens")` is a legitimate value, not an error.
162    ///
163    /// Spec: 2.3.0 §version_information_endpoint_moduleid_enum
164    pub enum ModuleId {
165        /// Charge Detail Records. Sender: CPO.
166        Cdrs = "cdrs",
167        /// Smart charging profiles.
168        ChargingProfiles = "chargingprofiles",
169        /// Remote commands: start, stop, reserve, unlock.
170        Commands = "commands",
171        /// Credentials and registration. Required for all implementations.
172        Credentials = "credentials",
173        /// Hub client info: which parties a hub has connected.
174        HubClientInfo = "hubclientinfo",
175        /// Charging locations, EVSEs and connectors. Sender: CPO.
176        Locations = "locations",
177        /// Payment terminals and financial advice confirmations. Sender: PTP.
178        ///
179        /// Added to the protocol in OCPI 2.3.0.
180        ///
181        /// **Spec erratum.** The Payments module chapter gives *"Module Identifier: `payments`"*,
182        /// but the module is missing from the `ModuleID` table in
183        /// §version_information_endpoint_moduleid_enum of the same release. The chapter is
184        /// normative for its own identifier, so `payments` is treated as a known module here.
185        Payments = "payments",
186        /// Charging sessions. Sender: CPO.
187        Sessions = "sessions",
188        /// Tariffs. Sender: CPO.
189        Tariffs = "tariffs",
190        /// Driver tokens and real-time authorization. Sender: eMSP.
191        Tokens = "tokens",
192        /// Versions and version details. Every implementation has this, but it is not listed as
193        /// an endpoint inside version details.
194        Versions = "versions",
195        /// Bookings, from the OCPI 2.3.0 `bookings` release branch.
196        ///
197        /// **Spec quirk.** The identifier really is `Booking`: singular, and the only module ID
198        /// in OCPI that is not lower-case. Every other module uses a lower-case plural. It is
199        /// also absent from that branch's `ModuleID` table. Peers that guessed `bookings`
200        /// exist; see [`ModuleId::matches`].
201        Booking = "Booking",
202        /// Invoice reconciliation, from the OCPI 2.3.0 `payments` release branch.
203        InvoiceReconciliation = "invoicereconciliation",
204    }
205}
206
207impl ModuleId {
208    /// Whether this module carries the message routing headers.
209    ///
210    /// > *Only requests/responses from Function Modules … SHALL be routed, so need the routing
211    /// > headers. The requests/responses to/from Configuration Modules: Credentials, Versions and
212    /// > Hub Client Info are not to be routed … Thus routing headers SHALL NOT be used with these
213    /// > modules.*
214    ///
215    /// A custom module is assumed to be functional, since that is what a party would define one
216    /// for.
217    ///
218    /// Spec: 2.3.0 §transport_and_format_message_routing
219    #[must_use]
220    pub const fn is_functional(&self) -> bool {
221        !self.is_configuration()
222    }
223
224    /// Whether this is one of the three configuration modules, which are never routed.
225    ///
226    /// Spec: 2.3.0 §transport_and_format_message_routing
227    #[must_use]
228    pub const fn is_configuration(&self) -> bool {
229        matches!(self, Self::Credentials | Self::Versions | Self::HubClientInfo)
230    }
231
232    /// Compares module identifiers the way a tolerant peer would.
233    ///
234    /// Two accommodations, both narrow and both deliberate:
235    ///
236    /// * ASCII case is ignored. Module identifiers are lower-case everywhere except `Booking`, so
237    ///   a peer that lower-cased the lot is still understood.
238    /// * `Booking` and `bookings` are treated as the same module. The Bookings chapter gives
239    ///   *"Module Identifier: `Booking`"* — singular, and the only mixed-case identifier in OCPI
240    ///   — while implementations that assumed the lower-case plural exist. Getting this wrong
241    ///   means silently not discovering the module, which is worse than accepting both.
242    ///
243    /// Use this when reading a peer's version details; use `==` when the exact value matters.
244    #[must_use]
245    pub fn matches(&self, other: &Self) -> bool {
246        if self.as_str().eq_ignore_ascii_case(other.as_str()) {
247            return true;
248        }
249        let is_bookings = |m: &Self| {
250            m.as_str().eq_ignore_ascii_case("Booking") || m.as_str().eq_ignore_ascii_case("bookings")
251        };
252        is_bookings(self) && is_bookings(other)
253    }
254
255    /// Whether this build of `ocpi-kit` has a wire model for this module.
256    #[must_use]
257    pub fn is_supported(&self) -> bool {
258        // Not a `matches!`: under some feature sets the `cfg!`s collapse to the same constant
259        // and under others they do not, so the arms must stay separate.
260        #[allow(clippy::match_like_matches_macro)]
261        match self {
262            Self::Booking => cfg!(feature = "bookings"),
263            Self::InvoiceReconciliation => cfg!(feature = "invoice-reconciliation"),
264            Self::Custom(_) => false,
265            _ => cfg!(any(feature = "v2_3_0", feature = "v2_2_1", feature = "v2_1_1")),
266        }
267    }
268
269    /// Whether the module exists in `version`.
270    ///
271    /// Spec: 2.2.1 and 2.3.0 §version_information_endpoint_moduleid_enum; 2.1.1
272    /// §version_information_endpoint (which has neither `hubclientinfo` nor `chargingprofiles`).
273    #[must_use]
274    pub fn exists_in(&self, version: &VersionNumber) -> bool {
275        match self {
276            // A `Custom` module is by definition not in any published table, so it is left to
277            // the peer that advertised it: assume it exists wherever it was offered.
278            Self::Credentials
279            | Self::Versions
280            | Self::Locations
281            | Self::Sessions
282            | Self::Cdrs
283            | Self::Tariffs
284            | Self::Tokens
285            | Self::Commands
286            | Self::Custom(_) => true,
287            Self::HubClientInfo | Self::ChargingProfiles => version.release_rank() >= 3,
288            Self::Payments | Self::Booking | Self::InvoiceReconciliation => *version == VersionNumber::V2_3_0,
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn unknown_versions_survive_discovery() {
299        let v: VersionNumber = "3.0".into();
300        assert!(!v.is_known());
301        assert!(!v.is_supported());
302        assert_eq!(v.as_str(), "3.0");
303    }
304
305    #[test]
306    fn release_order_sorts_by_release_not_by_text() {
307        let mut all =
308            vec![VersionNumber::V2_3_0, VersionNumber::V2_1_1, VersionNumber::V2_2_1, VersionNumber::V2_0];
309        all.sort_by(VersionNumber::cmp_by_release);
310        assert_eq!(
311            all,
312            vec![VersionNumber::V2_0, VersionNumber::V2_1_1, VersionNumber::V2_2_1, VersionNumber::V2_3_0]
313        );
314        assert!(VersionNumber::V2_3_0.release_rank() > VersionNumber::V2_2_1.release_rank());
315        // An unknown version sorts after every known one.
316        let future: VersionNumber = "3.0".into();
317        assert_eq!(future.cmp_by_release(&VersionNumber::V2_3_0), core::cmp::Ordering::Greater);
318    }
319
320    #[test]
321    fn configuration_modules_are_never_routed() {
322        for m in [ModuleId::Credentials, ModuleId::Versions, ModuleId::HubClientInfo] {
323            assert!(m.is_configuration() && !m.is_functional(), "{m} must not be routed");
324        }
325        for m in [ModuleId::Locations, ModuleId::Cdrs, ModuleId::Tokens, ModuleId::Payments] {
326            assert!(m.is_functional(), "{m} must be routed");
327        }
328        assert!(ModuleId::Custom("nltnm-tokens".into()).is_functional());
329    }
330
331    #[test]
332    fn booking_module_id_matches_case_insensitively() {
333        let lower: ModuleId = "bookings".into();
334        let spec: ModuleId = "Booking".into();
335        assert_eq!(spec, ModuleId::Booking);
336        assert!(!lower.is_known(), "\"bookings\" is not the identifier the spec gives");
337        assert!(ModuleId::Booking.matches(&"BOOKING".into()), "case is ignored");
338        assert!(ModuleId::Booking.matches(&lower), "the lower-case plural is accepted too");
339        assert!(!ModuleId::Booking.matches(&ModuleId::Cdrs));
340        assert_ne!(ModuleId::Booking, lower, "but they are still different values");
341    }
342
343    #[test]
344    fn module_availability_follows_the_version() {
345        assert!(!ModuleId::ChargingProfiles.exists_in(&VersionNumber::V2_1_1));
346        assert!(ModuleId::ChargingProfiles.exists_in(&VersionNumber::V2_2_1));
347        assert!(!ModuleId::Payments.exists_in(&VersionNumber::V2_2_1));
348        assert!(ModuleId::Payments.exists_in(&VersionNumber::V2_3_0));
349    }
350
351    #[test]
352    fn interface_roles_are_opposites() {
353        assert_eq!(InterfaceRole::Sender.opposite(), InterfaceRole::Receiver);
354        assert_eq!(serde_json::to_string(&InterfaceRole::Sender).unwrap(), "\"SENDER\"");
355    }
356}