1use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8use super::cistring::CiString;
9use super::string::OcpiString;
10use super::text::InvalidString;
11use super::validate::{Validate, Validator};
12use super::validate_fields;
13
14pub type CountryCode = CiString<2>;
21
22pub type PartyId = CiString<3>;
26
27pub type Currency = OcpiString<3>;
31
32pub type EvseId = CiString<48>;
36
37pub type ContractId = CiString<36>;
41
42pub trait CountryCodeExt {
44 fn is_iso_shaped(&self) -> bool;
49}
50
51impl CountryCodeExt for CountryCode {
52 fn is_iso_shaped(&self) -> bool {
53 self.len() == 2 && self.as_str().bytes().all(|b| b.is_ascii_alphabetic())
54 }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75pub struct PartyRef {
76 pub country_code: CountryCode,
78 pub party_id: PartyId,
80}
81
82impl PartyRef {
83 pub fn new(country_code: impl Into<String>, party_id: impl Into<String>) -> Result<Self, InvalidString> {
89 Ok(Self { country_code: CiString::new(country_code)?, party_id: CiString::new(party_id)? })
90 }
91
92 #[must_use]
98 pub fn to_hub_party_id(&self) -> CiString<5> {
99 CiString::new_lenient(format!("{}{}", self.country_code, self.party_id))
100 }
101
102 pub fn from_hub_party_id(value: &CiString<5>) -> Result<Self, InvalidString> {
108 let text = value.as_str();
109 if text.len() != 5 {
110 return Err(InvalidString::wrong_length(text.len(), 5, super::text::StringKind::Ci));
111 }
112 Self::new(&text[..2], &text[2..])
113 }
114}
115
116impl fmt::Display for PartyRef {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 write!(f, "{}/{}", self.country_code, self.party_id)
119 }
120}
121
122impl FromStr for PartyRef {
123 type Err = InvalidPartyRef;
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 let (country, party) = s
126 .split_once(['/', '*'])
127 .ok_or_else(|| InvalidPartyRef(format!("{s:?} is not \"<country>/<party>\"")))?;
128 Self::new(country, party).map_err(|e| InvalidPartyRef(e.to_string()))
129 }
130}
131
132impl Validate for PartyRef {
133 fn validate_in(&self, v: &mut Validator) {
134 validate_fields!(self, v, country_code, party_id);
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct InvalidPartyRef(String);
141
142impl fmt::Display for InvalidPartyRef {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 write!(f, "invalid party reference: {}", self.0)
145 }
146}
147impl std::error::Error for InvalidPartyRef {}
148
149#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct EvseIdParts {
173 pub country_code: String,
175 pub spot_operator: String,
177 pub power_outlet_id: String,
179}
180
181impl EvseIdParts {
182 #[must_use]
184 pub fn parse(id: &str) -> Option<Self> {
185 let stripped: String = id.chars().filter(|c| *c != '*').collect();
186 if stripped.len() < 7 {
188 return None;
189 }
190 let bytes = stripped.as_bytes();
191 if !bytes[..5].iter().all(u8::is_ascii_alphanumeric) {
192 return None;
193 }
194 if !bytes[..2].iter().all(u8::is_ascii_alphabetic) {
195 return None;
196 }
197 if !bytes[5].eq_ignore_ascii_case(&b'E') {
198 return None;
199 }
200 let outlet = &stripped[6..];
201 if outlet.is_empty() || !outlet.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'*' || b == b'-') {
202 return None;
203 }
204 Some(Self {
205 country_code: stripped[..2].to_owned(),
206 spot_operator: stripped[2..5].to_owned(),
207 power_outlet_id: outlet.to_owned(),
208 })
209 }
210
211 pub fn party(&self) -> Result<PartyRef, InvalidString> {
222 PartyRef::new(self.country_code.clone(), self.spot_operator.clone())
223 }
224
225 #[must_use]
227 pub fn to_separated(&self) -> String {
228 format!("{}*{}*E{}", self.country_code, self.spot_operator, self.power_outlet_id)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn a_hub_party_id_of_the_wrong_length_says_so() {
238 let round_trip = PartyRef::new("NL", "TNM").unwrap();
239 assert_eq!(round_trip.to_hub_party_id().as_str(), "NLTNM");
240 assert_eq!(PartyRef::from_hub_party_id(&round_trip.to_hub_party_id()).unwrap(), round_trip);
241
242 let short = CiString::<5>::new_lenient("NL");
245 let error = PartyRef::from_hub_party_id(&short).unwrap_err();
246 assert!(error.to_string().contains("exactly 5 characters"), "{error}");
247 assert!(!error.is_too_long());
248 }
249
250 #[test]
251 fn party_refs_compare_case_insensitively() {
252 assert_eq!(PartyRef::new("NL", "TNM").unwrap(), PartyRef::new("nl", "tnm").unwrap());
253 assert_eq!("NL/TNM".parse::<PartyRef>().unwrap(), PartyRef::new("NL", "TNM").unwrap());
254 assert!("NLTNM".parse::<PartyRef>().is_err());
255 }
256
257 #[test]
258 fn hub_party_id_is_the_concatenation() {
259 let p = PartyRef::new("NL", "TNM").unwrap();
260 let hub = p.to_hub_party_id();
261 assert_eq!(hub.as_str(), "NLTNM");
262 assert_eq!(PartyRef::from_hub_party_id(&hub).unwrap(), p);
263 }
264
265 #[test]
266 fn evse_id_parsing_accepts_both_forms_and_declines_others() {
267 let sep = EvseIdParts::parse("NL*TNM*E1234").unwrap();
268 assert_eq!(sep.to_separated(), "NL*TNM*E1234");
269 assert_eq!(EvseIdParts::parse("NLTNME1234").unwrap(), sep);
270 assert_eq!(sep.party().unwrap(), PartyRef::new("NL", "TNM").unwrap());
271 for other in ["", "short", "12*TNM*E1", "NL*TNM*X1234"] {
272 assert!(EvseIdParts::parse(other).is_none(), "{other} should not parse");
273 }
274 }
275
276 #[test]
277 fn country_code_shape_check_is_advisory() {
278 assert!(CountryCode::new("NL").unwrap().is_iso_shaped());
279 assert!(!CountryCode::new("N1").unwrap().is_iso_shaped());
280 }
281}