use std::fmt;
use std::str::FromStr;
pub const MAX_ID_LEN: usize = 30;
pub const MAX_BODY_LEN: usize = MAX_ID_LEN - 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum IdType {
Evse,
Pool,
Station,
}
impl IdType {
pub const fn as_char(self) -> char {
match self {
IdType::Evse => 'E',
IdType::Pool => 'P',
IdType::Station => 'S',
}
}
pub fn from_char(c: char) -> Option<IdType> {
match c.to_ascii_uppercase() {
'E' => Some(IdType::Evse),
'P' => Some(IdType::Pool),
'S' => Some(IdType::Station),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
Empty,
InvalidType(char),
InvalidCharacter(char),
TooLong { len: usize, max: usize },
CheckDigitMismatch { expected: char, found: char },
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Empty => f.write_str("identifier is empty"),
Error::InvalidType(c) => write!(f, "invalid type character '{c}', expected E, P or S"),
Error::InvalidCharacter(c) => {
write!(f, "invalid character '{c}', only [A-Za-z0-9] allowed")
}
Error::TooLong { len, max } => {
write!(f, "identifier part has {len} characters, maximum is {max}")
}
Error::CheckDigitMismatch { expected, found } => write!(
f,
"check character mismatch: expected '{expected}', found '{found}'"
),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct EvseId {
value: String,
}
impl EvseId {
pub fn generate(kind: IdType, body: &str) -> Result<EvseId, Error> {
if body.is_empty() {
return Err(Error::Empty);
}
if let Some(c) = body.chars().find(|c| !c.is_ascii_alphanumeric()) {
return Err(Error::InvalidCharacter(c));
}
if body.len() > MAX_BODY_LEN {
return Err(Error::TooLong {
len: body.len(),
max: MAX_BODY_LEN,
});
}
let mut value = String::with_capacity(body.len() + 2);
value.push(kind.as_char());
value.push_str(&body.to_ascii_uppercase());
let check = check_digit(&value)?;
value.push(check);
Ok(EvseId { value })
}
pub fn station(seq: u64) -> EvseId {
EvseId::generate(IdType::Station, &base36(seq, 6)).expect("valid body")
}
pub fn pool(seq: u64) -> EvseId {
EvseId::generate(IdType::Pool, &base36(seq, 6)).expect("valid body")
}
pub fn evse_of(station: &EvseId, evse_number: u32) -> Result<EvseId, Error> {
let station_body = station.body();
EvseId::generate(IdType::Evse, &format!("{station_body}{evse_number:02}"))
}
pub fn parse(s: &str) -> Result<EvseId, Error> {
let s = s.trim();
let mut chars = s.chars();
let t = chars.next().ok_or(Error::Empty)?;
let kind = IdType::from_char(t).ok_or(Error::InvalidType(t))?;
let rest = chars.as_str();
if rest.len() < 2 {
return Err(Error::Empty);
}
if let Some(c) = rest.chars().find(|c| !c.is_ascii_alphanumeric()) {
return Err(Error::InvalidCharacter(c));
}
if rest.len() > MAX_ID_LEN {
return Err(Error::TooLong {
len: rest.len(),
max: MAX_ID_LEN,
});
}
let upper = format!("{}{}", kind.as_char(), rest.to_ascii_uppercase());
let (payload, found) = upper.split_at(upper.len() - 1);
let expected = check_digit(payload)?;
let found = found.chars().next().expect("non-empty");
if expected != found {
return Err(Error::CheckDigitMismatch { expected, found });
}
Ok(EvseId { value: upper })
}
pub fn kind(&self) -> IdType {
IdType::from_char(self.value.as_bytes()[0] as char).expect("validated")
}
pub fn body(&self) -> &str {
&self.value[1..self.value.len() - 1]
}
pub fn check_char(&self) -> char {
self.value.as_bytes()[self.value.len() - 1] as char
}
pub fn as_str(&self) -> &str {
&self.value
}
pub fn with_operator(&self, country: &str, operator: &str) -> String {
format!(
"{}*{}*{}",
country.to_ascii_uppercase(),
operator.to_ascii_uppercase(),
self.value
)
}
}
impl fmt::Display for EvseId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.value)
}
}
impl FromStr for EvseId {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
EvseId::parse(s)
}
}
impl AsRef<str> for EvseId {
fn as_ref(&self) -> &str {
&self.value
}
}
fn base36(mut n: u64, width: usize) -> String {
const DIGITS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let mut buf = Vec::new();
loop {
buf.push(DIGITS[(n % 36) as usize]);
n /= 36;
if n == 0 {
break;
}
}
while buf.len() < width {
buf.push(b'0');
}
buf.reverse();
String::from_utf8(buf).expect("ascii")
}
type M2 = [[u8; 2]; 2];
type V2 = [u8; 2];
const P1: M2 = [[0, 1], [1, 1]];
const P2: M2 = [[0, 1], [1, 2]];
fn mat_mul(a: &M2, b: &M2, m: u8) -> M2 {
let mut r = [[0u8; 2]; 2];
for (i, row) in r.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = ((a[i][0] as u16 * b[0][j] as u16 + a[i][1] as u16 * b[1][j] as u16) % m as u16)
as u8;
}
}
r
}
fn mat_pow(p: &M2, e: u32, m: u8) -> M2 {
let mut r = [[1, 0], [0, 1]];
for _ in 0..e {
r = mat_mul(&r, p, m);
}
r
}
fn vec_mul(v: &V2, p: &M2, m: u8) -> V2 {
[
((v[0] as u16 * p[0][0] as u16 + v[1] as u16 * p[1][0] as u16) % m as u16) as u8,
((v[0] as u16 * p[0][1] as u16 + v[1] as u16 * p[1][1] as u16) % m as u16) as u8,
]
}
fn char_value(c: char) -> Option<u8> {
match c.to_ascii_uppercase() {
d @ '0'..='9' => Some(d as u8 - b'0'),
l @ 'A'..='Z' => Some(l as u8 - b'A' + 10),
_ => None,
}
}
pub fn check_digit(input: &str) -> Result<char, Error> {
if input.is_empty() {
return Err(Error::Empty);
}
let mut t1: V2 = [0, 0];
let mut t2: V2 = [0, 0];
let mut n = 0u32;
for c in input.chars() {
let a = char_value(c).ok_or(Error::InvalidCharacter(c))?;
n += 1;
let (q, r) = (a / 9, a % 9);
let qv: V2 = [q / 2, q % 2];
let rv: V2 = [r / 3, r % 3];
let a1 = vec_mul(&qv, &mat_pow(&P1, n % 3, 2), 2);
let a2 = vec_mul(&rv, &mat_pow(&P2, n % 8, 3), 3);
t1 = [(t1[0] + a1[0]) % 2, (t1[1] + a1[1]) % 2];
t2 = [(t2[0] + a2[0]) % 3, (t2[1] + a2[1]) % 3];
}
let k = n + 1;
let inv1 = mat_pow(&P1, (3 - k % 3) % 3, 2);
let inv2 = mat_pow(&P2, (8 - k % 8) % 8, 3);
let q = vec_mul(&t1, &inv1, 2); let r_pos = vec_mul(&t2, &inv2, 3);
let r = [(3 - r_pos[0]) % 3, (3 - r_pos[1]) % 3];
let value = (q[0] * 2 + q[1]) * 9 + (r[0] * 3 + r[1]);
Ok(if value < 10 {
(b'0' + value) as char
} else {
(b'A' + value - 10) as char
})
}
pub fn verify(input_with_check: &str) -> bool {
let Some((idx, last)) = input_with_check.char_indices().last() else {
return false;
};
idx > 0 && check_digit(&input_with_check[..idx]).is_ok_and(|c| c == last.to_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn annex1_worked_example() {
assert_eq!(check_digit("DE83DUIEN83QGZ").unwrap(), 'D');
assert!(verify("DE83DUIEN83QGZD"));
assert!(verify("de83duien83qgzd"));
assert!(!verify("DE83DUIEN83QGZE"));
}
#[test]
fn matrix_orders() {
assert_eq!(mat_pow(&P1, 3, 2), [[1, 0], [0, 1]]);
assert_eq!(mat_pow(&P2, 8, 3), [[1, 0], [0, 1]]);
assert_eq!(mat_pow(&P1, 2, 2), [[1, 1], [1, 0]]);
assert_eq!(mat_pow(&P2, 2, 3), [[1, 2], [2, 2]]);
assert_eq!(mat_pow(&P2, 5, 3), [[0, 2], [2, 1]]);
}
#[test]
fn detects_common_typing_errors() {
let base = "E1234ABCD56";
let good = format!("{base}{}", check_digit(base).unwrap());
assert!(verify(&good));
let bytes = good.as_bytes();
for i in 1..bytes.len() - 1 {
for c in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".bytes() {
if c == bytes[i] {
continue;
}
let mut b = bytes.to_vec();
b[i] = c;
assert!(
!verify(std::str::from_utf8(&b).unwrap()),
"substitution at {i}"
);
}
if bytes[i] != bytes[i + 1] {
let mut b = bytes.to_vec();
b.swap(i, i + 1);
assert!(
!verify(std::str::from_utf8(&b).unwrap()),
"transposition at {i}"
);
}
}
}
#[test]
fn generate_and_parse() {
let s = EvseId::station(1);
assert_eq!(s.kind(), IdType::Station);
assert_eq!(s.body(), "000001");
assert_eq!(EvseId::parse(s.as_str()).unwrap(), s);
assert_eq!(EvseId::parse(&s.as_str().to_ascii_lowercase()).unwrap(), s);
let e = EvseId::evse_of(&s, 2).unwrap();
assert_eq!(e.kind(), IdType::Evse);
assert_eq!(e.body(), "00000102");
assert!(verify(e.as_str()));
assert!(e.with_operator("fr", "abc").starts_with("FR*ABC*E00000102"));
assert_eq!(
EvseId::generate(IdType::Pool, "x-1"),
Err(Error::InvalidCharacter('-'))
);
assert!(matches!(
EvseId::generate(IdType::Pool, &"A".repeat(30)),
Err(Error::TooLong { .. })
));
assert!(EvseId::generate(IdType::Pool, &"A".repeat(29)).is_ok());
assert!(matches!(
EvseId::parse("X123"),
Err(Error::InvalidType('X'))
));
let mut bad = s.as_str().to_owned();
bad.replace_range(2..3, "9");
assert!(matches!(
EvseId::parse(&bad),
Err(Error::CheckDigitMismatch { .. })
));
assert_eq!(EvseId::station(36 * 36).body(), "000100");
}
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;