use crate::error::{IdentifierError, LengthExpectation};
fn char_value(c: char) -> Option<u32> {
match c {
'0'..='9' => Some(c as u32 - '0' as u32),
'A'..='Z' => Some(c as u32 - 'A' as u32 + 10),
'-' => Some(36),
_ => None,
}
}
fn value_to_char(v: u32) -> Option<char> {
match v {
0..=9 => char::from_digit(v, 10),
10..=35 => Some((b'A' + (v - 10) as u8) as char),
36 => Some('-'),
_ => None,
}
}
pub(crate) fn compute_check_char(prefix_bytes: &[u8; 15]) -> Option<char> {
let sum: u32 = prefix_bytes
.iter()
.enumerate()
.map(|(i, &b)| char_value(b as char).unwrap_or(0) * (16 - i as u32))
.sum();
if sum == 0 {
return None;
}
let check_number = 36 - (sum - 1) % 37;
if check_number == 36 {
return None;
}
value_to_char(check_number)
}
const EIC_TYPE_CHARS: &[char] = &['A', 'T', 'V', 'W', 'X', 'Y', 'Z'];
fn validate(s: &str) -> Result<(), IdentifierError> {
if !s.is_ascii() {
return Err(IdentifierError::InvalidFormat {
description: "EIC code must contain only ASCII characters".into(),
});
}
if s.len() != 16 {
return Err(IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(16),
actual: s.len(),
});
}
for (i, c) in s.chars().enumerate() {
if char_value(c).is_none() {
return Err(IdentifierError::InvalidCharacter {
position: i,
character: c,
});
}
}
let eic_type = s.as_bytes()[2] as char;
if !EIC_TYPE_CHARS.contains(&eic_type) {
return Err(IdentifierError::InvalidFormat {
description: "position 3 must be a valid EIC type character (A/T/V/W/X/Y/Z)".into(),
});
}
let prefix: &[u8; 15] = s.as_bytes()[..15]
.try_into()
.expect("length is verified to be 16 above");
let expected = compute_check_char(prefix).ok_or(IdentifierError::InvalidChecksum)?;
let actual = s.as_bytes()[15] as char;
if actual != expected {
return Err(IdentifierError::InvalidChecksum);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EicDomain {
Area,
Party,
}
impl std::fmt::Display for EicDomain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EicDomain::Area => f.write_str("Area"),
EicDomain::Party => f.write_str("Party"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "validate", derive(garde::Validate))]
#[cfg_attr(feature = "validate", garde(allow_unvalidated))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub struct EicCode(#[cfg_attr(feature = "validate", garde(custom(check_eic_code)))] Box<str>);
#[cfg(feature = "validate")]
fn check_eic_code(value: &str, _: &()) -> Result<(), garde::Error> {
validate(value).map_err(garde::Error::from)
}
impl EicCode {
#[must_use = "the validated identifier is returned; ignoring it discards the result"]
pub fn new(s: &str) -> Result<Self, IdentifierError> {
validate(s)?;
Ok(Self(Box::from(s)))
}
#[must_use]
pub fn domain(&self) -> EicDomain {
match self.type_char() {
'T' | 'V' => EicDomain::Party,
_ => EicDomain::Area, }
}
#[must_use]
pub fn type_char(&self) -> char {
self.0.as_bytes()[2] as char
}
pub fn compute_check_char(prefix: &str) -> Option<char> {
if prefix.len() != 15 || !prefix.is_ascii() {
return None;
}
let bytes: &[u8; 15] = prefix.as_bytes().try_into().ok()?;
compute_check_char(bytes)
}
}
impl TryFrom<String> for EicCode {
type Error = IdentifierError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(&s)
}
}
impl TryFrom<&str> for EicCode {
type Error = IdentifierError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl AsRef<str> for EicCode {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for EicCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for EicCode {
type Err = IdentifierError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for EicCode {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for EicCode {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = EicCode;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a 16-character ENTSO-E Energy Identification Code")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<EicCode, E> {
EicCode::new(v).map_err(|e| {
crate::identifiers::trace_identifier_deser_error("EicCode", v, &e);
serde::de::Error::custom(e)
})
}
}
d.deserialize_str(Visitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_valid_eic(prefix: &str) -> String {
let check = EicCode::compute_check_char(prefix)
.unwrap_or_else(|| panic!("could not compute check char for prefix: {prefix}"));
format!("{prefix}{check}")
}
#[test]
fn constructed_code_validates() {
let prefixes = [
"10XTEST--------", "11YTEST--------", "10ZFOO---------", "11WBAR---------", "10VBAZ---------", "11TQUX0--------", "10ASUB---------", ];
for prefix in prefixes {
let eic = make_valid_eic(prefix);
assert_eq!(eic.len(), 16, "{eic} should be 16 chars");
let code =
EicCode::new(&eic).unwrap_or_else(|e| panic!("{eic} should be valid but: {e}"));
assert_eq!(code.to_string().parse::<EicCode>().unwrap(), code);
}
}
#[test]
fn display_equals_input() {
let eic = make_valid_eic("10XTEST--------");
let code = EicCode::new(&eic).unwrap();
assert_eq!(code.to_string(), eic);
assert_eq!(code.as_ref(), eic.as_str());
}
#[test]
fn wrong_length_fails() {
assert!(matches!(
EicCode::new("10XTEST").unwrap_err(),
IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(16),
actual: 7
}
));
}
#[test]
fn too_long_fails() {
assert!(matches!(
EicCode::new("10XTEST-----------X").unwrap_err(),
IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(16),
actual: 19
}
));
}
#[test]
fn invalid_character_fails() {
let err = EicCode::new("10XTEST!--------").unwrap_err();
assert!(matches!(
err,
IdentifierError::InvalidCharacter {
position: 7,
character: '!'
}
));
}
#[test]
fn invalid_type_char_fails() {
let invalid_type = "10BTEST---------"; match EicCode::new(invalid_type).unwrap_err() {
IdentifierError::InvalidFormat { .. } => {}
other => panic!("expected InvalidFormat, got: {other}"),
}
}
#[test]
fn wrong_check_char_fails() {
let prefix = "10XTEST--------";
let correct = make_valid_eic(prefix);
let wrong_last = if correct.ends_with('A') { 'B' } else { 'A' };
let wrong: String = correct[..15].to_string() + &wrong_last.to_string();
assert!(matches!(
EicCode::new(&wrong).unwrap_err(),
IdentifierError::InvalidChecksum
));
}
#[test]
fn lowercase_input_fails() {
let err = EicCode::new("10xtest---------").unwrap_err();
assert!(matches!(
err,
IdentifierError::InvalidCharacter {
position: 2,
character: 'x'
}
));
}
#[test]
fn compute_check_char_wrong_length_returns_none() {
assert!(EicCode::compute_check_char("TOOSHORT").is_none());
assert!(EicCode::compute_check_char("TOOLONGPREFIXHERE").is_none());
}
#[test]
fn compute_check_char_is_deterministic() {
let prefix = "10XTEST--------";
assert_eq!(
EicCode::compute_check_char(prefix),
EicCode::compute_check_char(prefix)
);
}
#[test]
fn real_entso_e_german_tso_codes() {
let codes = [
"10YDE-EON------1", "10YDE-RWENET---I", "10YDE-VE-------2", "10YDE-ENBW-----N", ];
for code in codes {
assert!(
EicCode::new(code).is_ok(),
"Expected {code:?} to be a valid EIC code"
);
}
}
#[test]
fn real_entso_e_bidding_zone_code() {
assert!(EicCode::new("10Y1001A1001A82H").is_ok());
}
#[test]
fn check_char_matches_entso_e_published_codes() {
assert_eq!(EicCode::compute_check_char("10YDE-EON------"), Some('1'));
assert_eq!(EicCode::compute_check_char("10YDE-RWENET---"), Some('I'));
assert_eq!(EicCode::compute_check_char("10YDE-VE-------"), Some('2'));
assert_eq!(EicCode::compute_check_char("10YDE-ENBW-----"), Some('N'));
assert_eq!(EicCode::compute_check_char("10Y1001A1001A82"), Some('H'));
}
}