use core::fmt;
use core::str::FromStr;
use serde::{Deserialize, Serialize};
use super::cistring::CiString;
use super::string::OcpiString;
use super::text::InvalidString;
use super::validate::{Validate, Validator};
use super::validate_fields;
pub type CountryCode = CiString<2>;
pub type PartyId = CiString<3>;
pub type Currency = OcpiString<3>;
pub type EvseId = CiString<48>;
pub type ContractId = CiString<36>;
pub trait CountryCodeExt {
fn is_iso_shaped(&self) -> bool;
}
impl CountryCodeExt for CountryCode {
fn is_iso_shaped(&self) -> bool {
self.len() == 2 && self.as_str().bytes().all(|b| b.is_ascii_alphabetic())
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PartyRef {
pub country_code: CountryCode,
pub party_id: PartyId,
}
impl PartyRef {
pub fn new(country_code: impl Into<String>, party_id: impl Into<String>) -> Result<Self, InvalidString> {
Ok(Self { country_code: CiString::new(country_code)?, party_id: CiString::new(party_id)? })
}
#[must_use]
pub fn to_hub_party_id(&self) -> CiString<5> {
CiString::new_lenient(format!("{}{}", self.country_code, self.party_id))
}
pub fn from_hub_party_id(value: &CiString<5>) -> Result<Self, InvalidString> {
let text = value.as_str();
if text.len() != 5 {
return Err(InvalidString::wrong_length(text.len(), 5, super::text::StringKind::Ci));
}
Self::new(&text[..2], &text[2..])
}
}
impl fmt::Display for PartyRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.country_code, self.party_id)
}
}
impl FromStr for PartyRef {
type Err = InvalidPartyRef;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (country, party) = s
.split_once(['/', '*'])
.ok_or_else(|| InvalidPartyRef(format!("{s:?} is not \"<country>/<party>\"")))?;
Self::new(country, party).map_err(|e| InvalidPartyRef(e.to_string()))
}
}
impl Validate for PartyRef {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, country_code, party_id);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidPartyRef(String);
impl fmt::Display for InvalidPartyRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid party reference: {}", self.0)
}
}
impl std::error::Error for InvalidPartyRef {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EvseIdParts {
pub country_code: String,
pub spot_operator: String,
pub power_outlet_id: String,
}
impl EvseIdParts {
#[must_use]
pub fn parse(id: &str) -> Option<Self> {
let stripped: String = id.chars().filter(|c| *c != '*').collect();
if stripped.len() < 7 {
return None;
}
let bytes = stripped.as_bytes();
if !bytes[..5].iter().all(u8::is_ascii_alphanumeric) {
return None;
}
if !bytes[..2].iter().all(u8::is_ascii_alphabetic) {
return None;
}
if !bytes[5].eq_ignore_ascii_case(&b'E') {
return None;
}
let outlet = &stripped[6..];
if outlet.is_empty() || !outlet.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'*' || b == b'-') {
return None;
}
Some(Self {
country_code: stripped[..2].to_owned(),
spot_operator: stripped[2..5].to_owned(),
power_outlet_id: outlet.to_owned(),
})
}
pub fn party(&self) -> Result<PartyRef, InvalidString> {
PartyRef::new(self.country_code.clone(), self.spot_operator.clone())
}
#[must_use]
pub fn to_separated(&self) -> String {
format!("{}*{}*E{}", self.country_code, self.spot_operator, self.power_outlet_id)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContractIdParts {
pub country_code: String,
pub provider_id: String,
pub instance: String,
pub check_digit: Option<char>,
}
impl ContractIdParts {
#[must_use]
pub fn parse(id: &str) -> Option<Self> {
let stripped: String = id.chars().filter(|c| *c != '-' && *c != '*').collect();
if !(stripped.len() == 14 || stripped.len() == 15) {
return None;
}
if !stripped.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
if !stripped.as_bytes()[..2].iter().all(u8::is_ascii_alphabetic) {
return None;
}
Some(Self {
country_code: stripped[..2].to_owned(),
provider_id: stripped[2..5].to_owned(),
instance: stripped[5..14].to_owned(),
check_digit: stripped[14..].chars().next(),
})
}
#[must_use]
pub fn normalise(id: &str) -> Option<String> {
Self::parse(id).map(|p| p.to_compact())
}
pub fn party(&self) -> Result<PartyRef, InvalidString> {
PartyRef::new(self.country_code.clone(), self.provider_id.clone())
}
#[must_use]
pub fn to_compact(&self) -> String {
let mut out = String::with_capacity(15);
out.push_str(&self.country_code);
out.push_str(&self.provider_id);
out.push_str(&self.instance);
if let Some(check) = self.check_digit {
out.push(check);
}
out.make_ascii_uppercase();
out
}
#[must_use]
pub fn to_separated(&self) -> String {
let compact = self.to_compact();
let mut out = format!("{}-{}-{}", &compact[..2], &compact[2..5], &compact[5..14]);
if let Some(check) = compact[14..].chars().next() {
out.push('-');
out.push(check);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hub_party_id_of_the_wrong_length_says_so() {
let round_trip = PartyRef::new("NL", "TNM").unwrap();
assert_eq!(round_trip.to_hub_party_id().as_str(), "NLTNM");
assert_eq!(PartyRef::from_hub_party_id(&round_trip.to_hub_party_id()).unwrap(), round_trip);
let short = CiString::<5>::new_lenient("NL");
let error = PartyRef::from_hub_party_id(&short).unwrap_err();
assert!(error.to_string().contains("exactly 5 characters"), "{error}");
assert!(!error.is_too_long());
}
#[test]
fn party_refs_compare_case_insensitively() {
assert_eq!(PartyRef::new("NL", "TNM").unwrap(), PartyRef::new("nl", "tnm").unwrap());
assert_eq!("NL/TNM".parse::<PartyRef>().unwrap(), PartyRef::new("NL", "TNM").unwrap());
assert!("NLTNM".parse::<PartyRef>().is_err());
}
#[test]
fn hub_party_id_is_the_concatenation() {
let p = PartyRef::new("NL", "TNM").unwrap();
let hub = p.to_hub_party_id();
assert_eq!(hub.as_str(), "NLTNM");
assert_eq!(PartyRef::from_hub_party_id(&hub).unwrap(), p);
}
#[test]
fn evse_id_parsing_accepts_both_forms_and_declines_others() {
let sep = EvseIdParts::parse("NL*TNM*E1234").unwrap();
assert_eq!(sep.to_separated(), "NL*TNM*E1234");
assert_eq!(EvseIdParts::parse("NLTNME1234").unwrap(), sep);
assert_eq!(sep.party().unwrap(), PartyRef::new("NL", "TNM").unwrap());
for other in ["", "short", "12*TNM*E1", "NL*TNM*X1234"] {
assert!(EvseIdParts::parse(other).is_none(), "{other} should not parse");
}
}
#[test]
fn country_code_shape_check_is_advisory() {
assert!(CountryCode::new("NL").unwrap().is_iso_shaped());
assert!(!CountryCode::new("N1").unwrap().is_iso_shaped());
}
}