Skip to main content

ocpi_kit/types/
open_enum.rs

1//! The two enum shapes OCPI 2.3.0 distinguishes, as declarative macros.
2//!
3//! OCPI 2.3.0 formalised the difference between an `enum` and an `OpenEnum`
4//! (§types_enum_type, §types_openenum_type):
5//!
6//! * An **enum** has *"a finite number of strings … completely known at the time of writing of
7//!   the specification"*. An unknown value is a protocol error.
8//! * An **OpenEnum** is for fields *"for which the set of all possible values is not known at
9//!   the time of writing"*. Implementers are expected to add their own values, following
10//!   [RFC 6648](https://datatracker.ietf.org/doc/html/rfc6648).
11//!
12//! [`ocpi_enum!`](crate::ocpi_enum) and [`ocpi_open_enum!`](crate::ocpi_open_enum) generate the
13//! two shapes. The important difference is what happens to a value the crate has never heard of:
14//! a closed enum refuses it, an open enum **keeps it** in a `Custom` variant so that a hub or a
15//! pull-store-push pipeline hands it on untouched. Discarding it — which is what a plain
16//! `#[derive(Deserialize)]` enum does — would make this crate lossy in exactly the place OCPI's
17//! extensibility chapter cares about.
18//!
19//! Both macros are exported, so a party defining a custom module can use them for its own types.
20
21/// Why a string is not a member of a closed OCPI enum.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct UnknownVariant {
24    enum_name: &'static str,
25    value: String,
26    allowed: &'static [&'static str],
27}
28
29impl UnknownVariant {
30    /// Creates an error for `value`, which is not one of `allowed`.
31    #[must_use]
32    pub fn new(enum_name: &'static str, value: impl Into<String>, allowed: &'static [&'static str]) -> Self {
33        Self { enum_name, value: value.into(), allowed }
34    }
35
36    /// The value that was not recognised.
37    #[must_use]
38    pub fn value(&self) -> &str {
39        &self.value
40    }
41
42    /// The name of the enum that rejected it.
43    #[must_use]
44    pub const fn enum_name(&self) -> &'static str {
45        self.enum_name
46    }
47
48    /// Every value the enum does accept.
49    #[must_use]
50    pub const fn allowed(&self) -> &'static [&'static str] {
51        self.allowed
52    }
53}
54
55impl core::fmt::Display for UnknownVariant {
56    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
57        write!(f, "{:?} is not a valid {}; expected one of ", self.value, self.enum_name)?;
58        for (i, a) in self.allowed.iter().enumerate() {
59            if i > 0 {
60                f.write_str(", ")?;
61            }
62            write!(f, "{a}")?;
63        }
64        Ok(())
65    }
66}
67
68impl std::error::Error for UnknownVariant {}
69
70/// Defines a **closed** OCPI `enum`: a fixed set of strings, where anything else is an error.
71///
72/// ```
73/// use ocpi_kit::ocpi_enum;
74///
75/// ocpi_enum! {
76///     /// The format of the connector, whether it is a socket or a plug.
77///     ///
78///     /// Spec: 2.3.0 §mod_locations_connectorformat_enum
79///     pub enum ConnectorFormat {
80///         /// The connector is a socket; the EV user needs to bring a fitting plug.
81///         Socket = "SOCKET",
82///         /// The connector is an attached cable.
83///         Cable = "CABLE",
84///     }
85/// }
86///
87/// assert_eq!(ConnectorFormat::Socket.as_str(), "SOCKET");
88/// assert!("SCREW".parse::<ConnectorFormat>().is_err());
89/// ```
90///
91/// Attributes that document the enum — `#[cfg_attr(docsrs, doc(cfg(…)))]` above all — go **inside**
92/// the invocation, on the `pub enum` line, so they land on the item this expands to. On the
93/// invocation itself they document nothing, and rustdoc refuses them. A `#[cfg]` is the exception:
94/// it belongs outside, where it gates the whole expansion rather than the enum alone.
95#[macro_export]
96macro_rules! ocpi_enum {
97    (
98        $(#[$meta:meta])*
99        $vis:vis enum $name:ident {
100            $(
101                $(#[$vmeta:meta])*
102                $variant:ident = $wire:literal
103            ),* $(,)?
104        }
105    ) => {
106        $(#[$meta])*
107        #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
108        $vis enum $name {
109            $(
110                $(#[$vmeta])*
111                #[doc = concat!("\n\nWire value: `", $wire, "`")]
112                $variant,
113            )*
114        }
115
116        impl $name {
117            /// Every value this enum accepts, in declaration order.
118            pub const ALL: &'static [Self] = &[ $( Self::$variant ),* ];
119            /// Every wire value this enum accepts, in declaration order.
120            pub const ALL_WIRE: &'static [&'static str] = &[ $( $wire ),* ];
121
122            /// The value as it appears on the wire.
123            #[must_use]
124            pub const fn as_str(&self) -> &'static str {
125                match self { $( Self::$variant => $wire, )* }
126            }
127
128            /// Parses a wire value, ignoring ASCII case.
129            ///
130            /// OCPI enum values are case-sensitive, so this is only for peers known to get the
131            /// case wrong; [`FromStr`](core::str::FromStr) is the strict version.
132            #[must_use]
133            pub fn from_str_ignore_case(s: &str) -> Option<Self> {
134                $( if s.eq_ignore_ascii_case($wire) { return Some(Self::$variant); } )*
135                None
136            }
137        }
138
139        impl core::fmt::Display for $name {
140            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
141                f.write_str(self.as_str())
142            }
143        }
144
145        impl core::str::FromStr for $name {
146            type Err = $crate::types::UnknownVariant;
147            fn from_str(s: &str) -> Result<Self, Self::Err> {
148                match s {
149                    $( $wire => Ok(Self::$variant), )*
150                    other => Err($crate::types::UnknownVariant::new(
151                        stringify!($name), other, Self::ALL_WIRE,
152                    )),
153                }
154            }
155        }
156
157        impl $crate::types::Validate for $name {
158            fn validate_in(&self, _v: &mut $crate::types::Validator) {}
159        }
160
161        impl serde::Serialize for $name {
162            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
163                s.serialize_str(self.as_str())
164            }
165        }
166
167        impl<'de> serde::Deserialize<'de> for $name {
168            fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
169                struct V;
170                impl serde::de::Visitor<'_> for V {
171                    type Value = $name;
172                    fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
173                        write!(f, "one of the {} values of {}", $name::ALL_WIRE.len(), stringify!($name))
174                    }
175                    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<$name, E> {
176                        <$name as core::str::FromStr>::from_str(v).map_err(E::custom)
177                    }
178                }
179                d.deserialize_str(V)
180            }
181        }
182
183        #[cfg(feature = "schema")]
184        impl schemars::JsonSchema for $name {
185            fn schema_name() -> std::borrow::Cow<'static, str> { stringify!($name).into() }
186            fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
187                schemars::json_schema!({ "type": "string", "enum": Self::ALL_WIRE })
188            }
189        }
190    };
191}
192
193/// Defines an OCPI `OpenEnum`: known values plus a `Custom` variant that preserves anything else.
194///
195/// ```
196/// use ocpi_kit::ocpi_open_enum;
197///
198/// ocpi_open_enum! {
199///     /// Categories of environmental impact values.
200///     ///
201///     /// Spec: 2.3.0 §mod_locations_environmentalimpactcategory_enum
202///     pub enum EnvironmentalImpactCategory {
203///         /// Produced nuclear waste in grams per kilowatthour.
204///         NuclearWaste = "NUCLEAR_WASTE",
205///         /// Exhausted carbon dioxide in grams per kilowatthour.
206///         CarbonDioxide = "CARBON_DIOXIDE",
207///     }
208/// }
209///
210/// let vendor: EnvironmentalImpactCategory = "nltnm-METHANE".parse().unwrap();
211/// assert!(!vendor.is_known());
212/// assert_eq!(vendor.as_str(), "nltnm-METHANE"); // never dropped, never rewritten
213/// ```
214#[macro_export]
215macro_rules! ocpi_open_enum {
216    (
217        $(#[$meta:meta])*
218        $vis:vis enum $name:ident {
219            $(
220                $(#[$vmeta:meta])*
221                $variant:ident = $wire:literal
222            ),* $(,)?
223        }
224    ) => {
225        $crate::__ocpi_open_enum_impl! {
226            @policy $crate::types::validate_open_enum_value;
227            $(#[$meta])*
228            ///
229            /// This is an OCPI `OpenEnum`: a value this version of the specification does not
230            /// define is a legitimate extension, and [`Validate`](crate::types::Validate) does
231            /// not report it.
232            $vis enum $name { $( $(#[$vmeta])* $variant = $wire, )* }
233        }
234    };
235}
236
237/// Defines an enum the specification declares **closed**, but which this crate still accepts
238/// unknown values for — and reports them.
239///
240/// OCPI 2.2.1 has no `OpenEnum` at all: `ConnectorType`, `TokenType` and the rest are closed, so
241/// by the letter of that specification an unrecognised connector type is a decode error. In
242/// practice new plug standards appear faster than OCPI releases — OCPI 2.3.0 reclassified
243/// exactly these enums as `OpenEnum` for that reason — and refusing the value would make a whole
244/// page of Locations undecodable over one connector nobody has heard of.
245///
246/// So the value is kept, and [`Validate`](crate::types::Validate) reports it as a
247/// [`ViolationCode::Inconsistent`](crate::types::ViolationCode::Inconsistent) violation. Decoding
248/// succeeds; a conformance report still says the peer sent something its own version does not
249/// define.
250#[macro_export]
251macro_rules! ocpi_lenient_enum {
252    (
253        $(#[$meta:meta])*
254        $vis:vis enum $name:ident {
255            $(
256                $(#[$vmeta:meta])*
257                $variant:ident = $wire:literal
258            ),* $(,)?
259        }
260    ) => {
261        $crate::__ocpi_open_enum_impl! {
262            @policy $crate::types::validate_closed_enum_value;
263            $(#[$meta])*
264            ///
265            /// The specification declares this enum **closed**. This crate still decodes an
266            /// unrecognised value into [`Custom`](Self::Custom) rather than failing the whole
267            /// object, and [`Validate`](crate::types::Validate) reports it.
268            $vis enum $name { $( $(#[$vmeta])* $variant = $wire, )* }
269        }
270    };
271}
272
273#[doc(hidden)]
274#[macro_export]
275macro_rules! __ocpi_open_enum_impl {
276    (
277        @policy $policy:path;
278        $(#[$meta:meta])*
279        $vis:vis enum $name:ident {
280            $(
281                $(#[$vmeta:meta])*
282                $variant:ident = $wire:literal
283            ),* $(,)?
284        }
285    ) => {
286        $(#[$meta])*
287        #[derive(Clone, Debug)]
288        #[non_exhaustive]
289        $vis enum $name {
290            $(
291                $(#[$vmeta])*
292                #[doc = concat!("\n\nWire value: `", $wire, "`")]
293                $variant,
294            )*
295            /// A value this version of the specification does not define, preserved verbatim.
296            ///
297            /// The variant is named `Custom` rather than `Other` because several OCPI OpenEnums
298            /// have a defined value that is literally `OTHER`.
299            Custom(String),
300        }
301
302        impl $name {
303            /// Every value this version of the specification defines, in declaration order.
304            pub const ALL_KNOWN: &'static [Self] = &[ $( Self::$variant ),* ];
305            /// Every wire value this version of the specification defines.
306            pub const ALL_KNOWN_WIRE: &'static [&'static str] = &[ $( $wire ),* ];
307
308            /// The value as it appears on the wire.
309            #[must_use]
310            pub fn as_str(&self) -> &str {
311                match self {
312                    $( Self::$variant => $wire, )*
313                    Self::Custom(v) => v.as_str(),
314                }
315            }
316
317            /// Whether this is a value the specification defines.
318            #[must_use]
319            pub const fn is_known(&self) -> bool {
320                !matches!(self, Self::Custom(_))
321            }
322
323            /// Parses a wire value, ignoring ASCII case for the known values.
324            ///
325            /// For peers that get the case wrong; [`FromStr`](core::str::FromStr) is strict.
326            #[must_use]
327            pub fn from_str_ignore_case(s: &str) -> Self {
328                $( if s.eq_ignore_ascii_case($wire) { return Self::$variant; } )*
329                Self::Custom(s.to_owned())
330            }
331        }
332
333        impl core::fmt::Display for $name {
334            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
335                f.write_str(self.as_str())
336            }
337        }
338
339        impl core::str::FromStr for $name {
340            // Parsing an OpenEnum cannot fail: an unrecognised value is a legitimate value.
341            type Err = core::convert::Infallible;
342            fn from_str(s: &str) -> Result<Self, Self::Err> {
343                Ok(match s {
344                    $( $wire => Self::$variant, )*
345                    other => Self::Custom(other.to_owned()),
346                })
347            }
348        }
349
350        impl From<&str> for $name {
351            fn from(s: &str) -> Self {
352                <Self as core::str::FromStr>::from_str(s).unwrap_or_else(|e| match e {})
353            }
354        }
355
356        // Comparison goes through the wire value, so a value that reached `Custom` by another
357        // route still equals the variant it names.
358        impl PartialEq for $name {
359            fn eq(&self, other: &Self) -> bool { self.as_str() == other.as_str() }
360        }
361        impl Eq for $name {}
362        impl PartialOrd for $name {
363            fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> { Some(self.cmp(other)) }
364        }
365        impl Ord for $name {
366            fn cmp(&self, other: &Self) -> core::cmp::Ordering { self.as_str().cmp(other.as_str()) }
367        }
368        impl core::hash::Hash for $name {
369            fn hash<H: core::hash::Hasher>(&self, state: &mut H) { self.as_str().hash(state); }
370        }
371
372        impl $crate::types::Validate for $name {
373            fn validate_in(&self, v: &mut $crate::types::Validator) {
374                if let Self::Custom(value) = self {
375                    $policy(stringify!($name), value, v);
376                }
377            }
378        }
379
380        impl serde::Serialize for $name {
381            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
382                s.serialize_str(self.as_str())
383            }
384        }
385
386        impl<'de> serde::Deserialize<'de> for $name {
387            fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
388                struct V;
389                impl serde::de::Visitor<'_> for V {
390                    type Value = $name;
391                    fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
392                        write!(f, "a {} value", stringify!($name))
393                    }
394                    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<$name, E> {
395                        Ok(<$name as core::convert::From<&str>>::from(v))
396                    }
397                }
398                d.deserialize_str(V)
399            }
400        }
401
402        #[cfg(feature = "schema")]
403        impl schemars::JsonSchema for $name {
404            fn schema_name() -> std::borrow::Cow<'static, str> { stringify!($name).into() }
405            fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
406                // An OpenEnum accepts any string; the known values are advertised as examples.
407                schemars::json_schema!({ "type": "string", "examples": Self::ALL_KNOWN_WIRE })
408            }
409        }
410    };
411}
412
413/// Shared validation for the `Custom` payload of an [`ocpi_lenient_enum!`] type: the value is
414/// kept, but reported, because the specification version in question declares the enum closed.
415///
416/// Not part of the public contract; called by the generated code.
417#[doc(hidden)]
418pub fn validate_closed_enum_value(enum_name: &'static str, value: &str, v: &mut super::validate::Validator) {
419    use super::validate::ViolationCode;
420    validate_open_enum_value(enum_name, value, v);
421    v.report(
422        ViolationCode::Inconsistent,
423        format!(
424            "{value:?} is not one of the values this version of the specification defines for \
425             {enum_name}, which it declares as a closed enum; the value was kept rather than \
426             dropped, but a conformant peer would not have sent it"
427        ),
428    );
429}
430
431/// Shared validation for the `Custom` payload of every [`ocpi_open_enum!`] type.
432///
433/// An unrecognised value is a legitimate extension, so the value itself is never reported. What
434/// is reported is a value that could not have come off a conformant wire at all: an empty string,
435/// or one carrying a control character.
436///
437/// Not part of the public contract; called by the generated code.
438#[doc(hidden)]
439pub fn validate_open_enum_value(enum_name: &'static str, value: &str, v: &mut super::validate::Validator) {
440    use super::validate::ViolationCode;
441    if value.is_empty() {
442        v.report(ViolationCode::IllegalCharacter, format!("{enum_name} value is empty"));
443        return;
444    }
445    if value.chars().any(char::is_control) {
446        v.report(
447            ViolationCode::IllegalCharacter,
448            format!("{enum_name} value {value:?} contains a control character"),
449        );
450    }
451}
452
453#[cfg(test)]
454#[allow(dead_code, reason = "the generated enums expose more API than each test exercises")]
455mod tests {
456    use crate::types::Validate;
457    use core::str::FromStr;
458
459    crate::ocpi_enum! {
460        /// Test-only closed enum.
461        pub enum Closed {
462            /// a
463            Alpha = "ALPHA",
464            /// b
465            Beta = "BETA",
466        }
467    }
468
469    crate::ocpi_open_enum! {
470        /// Test-only open enum.
471        pub enum Open {
472            /// a
473            Alpha = "ALPHA",
474        }
475    }
476
477    #[test]
478    fn closed_enum_rejects_unknown_values() {
479        assert_eq!(Closed::from_str("ALPHA").unwrap(), Closed::Alpha);
480        let err = serde_json::from_str::<Closed>("\"GAMMA\"").unwrap_err().to_string();
481        assert!(err.contains("GAMMA") && err.contains("ALPHA"), "{err}");
482        assert_eq!(Closed::ALL.len(), 2);
483    }
484
485    #[test]
486    fn open_enum_preserves_unknown_values_verbatim() {
487        let v: Open = serde_json::from_str("\"nltnm-CUSTOM\"").unwrap();
488        assert!(!v.is_known());
489        assert_eq!(serde_json::to_string(&v).unwrap(), "\"nltnm-CUSTOM\"");
490    }
491
492    #[test]
493    fn open_enum_equality_goes_through_the_wire_value() {
494        use std::collections::HashSet;
495        assert_eq!(Open::Custom("ALPHA".into()), Open::Alpha);
496        let mut set = HashSet::new();
497        set.insert(Open::Custom("ALPHA".into()));
498        assert!(set.contains(&Open::Alpha), "Hash must agree with Eq");
499    }
500
501    #[test]
502    fn case_insensitive_parsing_is_opt_in() {
503        assert_eq!(Open::from_str("alpha").unwrap(), Open::Custom("alpha".into()));
504        assert_eq!(Open::from_str_ignore_case("alpha"), Open::Alpha);
505        assert_eq!(Closed::from_str_ignore_case("beta"), Some(Closed::Beta));
506    }
507
508    crate::ocpi_lenient_enum! {
509        /// Test-only enum that the specification declares closed.
510        pub enum ClosedInSpec {
511            /// a
512            Alpha = "ALPHA",
513        }
514    }
515
516    #[test]
517    fn a_closed_in_spec_enum_decodes_an_unknown_value_and_reports_it() {
518        let v: ClosedInSpec = serde_json::from_str("\"MCS\"").unwrap();
519        assert!(!v.is_known());
520        // Decoding succeeded: one unknown connector type must not lose a page of Locations.
521        assert_eq!(serde_json::to_string(&v).unwrap(), "\"MCS\"");
522        // But a conformance report still says the peer sent something out of spec.
523        let err = v.validate().unwrap_err();
524        assert_eq!(err.as_slice()[0].code, crate::types::ViolationCode::Inconsistent);
525        assert!(ClosedInSpec::Alpha.validate().is_ok());
526    }
527
528    #[test]
529    fn open_enum_other_payload_is_validated() {
530        assert!(Open::Custom("fine".into()).validate().is_ok());
531        assert!(Open::Custom(String::new()).validate().is_err());
532        assert!(Open::Custom("bad\nvalue".into()).validate().is_err());
533    }
534}