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();
let check_number = 36 - (sum + 36) % 37;
if check_number == 36 {
return None;
}
value_to_char(check_number)
}
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,
});
}
}
if EicType::from_char(s.as_bytes()[2] as char).is_none() {
return Err(IdentifierError::InvalidFormat {
description: "position 3 must be a valid EIC object-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, PartialOrd, Ord, Hash)]
pub enum EicType {
Substation,
Tieline,
Location,
ResourceObject,
Party,
Area,
MeasurementPoint,
}
impl EicType {
pub const ALL: [EicType; 7] = [
EicType::Substation,
EicType::Tieline,
EicType::Location,
EicType::ResourceObject,
EicType::Party,
EicType::Area,
EicType::MeasurementPoint,
];
#[must_use]
pub const fn as_char(self) -> char {
match self {
EicType::Substation => 'A',
EicType::Tieline => 'T',
EicType::Location => 'V',
EicType::ResourceObject => 'W',
EicType::Party => 'X',
EicType::Area => 'Y',
EicType::MeasurementPoint => 'Z',
}
}
#[must_use]
pub const fn from_char(c: char) -> Option<EicType> {
match c {
'A' => Some(EicType::Substation),
'T' => Some(EicType::Tieline),
'V' => Some(EicType::Location),
'W' => Some(EicType::ResourceObject),
'X' => Some(EicType::Party),
'Y' => Some(EicType::Area),
'Z' => Some(EicType::MeasurementPoint),
_ => None,
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
EicType::Substation => "Substation",
EicType::Tieline => "Tieline",
EicType::Location => "Location",
EicType::ResourceObject => "Resource Object",
EicType::Party => "Party",
EicType::Area => "Area or Domain",
EicType::MeasurementPoint => "Measurement Point",
}
}
}
impl std::fmt::Display for EicType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.description())
}
}
#[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(schema_with = "crate::schema_helpers::eic_code_schema")
)]
#[cfg_attr(feature = "schemars", schemars(description = crate::identifiers::schema::EIC_CODE.description))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(
value_type = String,
pattern = r"^[A-Z0-9]{2}[ATVWXYZ][A-Z0-9-]{12}[A-Z0-9]$",
example = "10YDE-EON------1",
description = crate::identifiers::schema::EIC_CODE.description
))]
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 eic_type(&self) -> EicType {
EicType::from_char(self.type_char())
.expect("EicCode invariant: position 3 is validated at construction")
}
#[must_use]
pub fn type_char(&self) -> char {
self.0.as_bytes()[2] as char
}
pub fn new_from_prefix(prefix: &str) -> Result<Self, IdentifierError> {
Self::new(&Self::complete_prefix(prefix)?)
}
pub(super) fn complete_prefix(prefix: &str) -> Result<String, IdentifierError> {
if prefix.len() != 15 {
return Err(IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(15),
actual: prefix.len(),
});
}
if !prefix.is_ascii() {
return Err(IdentifierError::InvalidFormat {
description: "EIC prefix must contain only ASCII characters".into(),
});
}
let bytes: &[u8; 15] = prefix.as_bytes().try_into().expect("length checked above");
let check = compute_check_char(bytes).ok_or(IdentifierError::InvalidChecksum)?;
let mut out = String::with_capacity(16);
out.push_str(prefix);
out.push(check);
Ok(out)
}
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_identifier_traits!(EicCode, "a 16-character ENTSO-E Energy Identification Code");
#[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 a_zero_sum_prefix_has_a_check_character() {
assert_eq!(EicCode::compute_check_char("000000000000000"), Some('0'));
}
#[test]
fn a_prefix_needing_a_dash_has_no_completion() {
let prefix = "000000000000087";
assert_eq!(prefix.len(), 15);
assert_eq!(EicCode::compute_check_char(prefix), None);
assert!(matches!(
EicCode::new_from_prefix(prefix),
Err(IdentifierError::InvalidChecksum)
));
}
#[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'));
}
#[test]
fn eic_type_char_mapping_matches_entso_e() {
for (c, want, desc) in [
('A', EicType::Substation, "Substation"),
('T', EicType::Tieline, "Tieline"),
('V', EicType::Location, "Location"),
('W', EicType::ResourceObject, "Resource Object"),
('X', EicType::Party, "Party"),
('Y', EicType::Area, "Area or Domain"),
('Z', EicType::MeasurementPoint, "Measurement Point"),
] {
assert_eq!(EicType::from_char(c), Some(want), "from_char({c:?})");
assert_eq!(want.as_char(), c, "as_char() for {want:?}");
assert_eq!(want.description(), desc);
assert_eq!(want.to_string(), desc);
}
}
#[test]
fn eic_type_all_round_trips_and_is_exhaustive() {
assert_eq!(EicType::ALL.len(), 7);
for t in EicType::ALL {
assert_eq!(EicType::from_char(t.as_char()), Some(t));
}
for c in 'A'..='Z' {
let accepted = EicType::ALL.iter().any(|t| t.as_char() == c);
assert_eq!(
EicType::from_char(c).is_some(),
accepted,
"from_char({c:?}) disagrees with ALL"
);
}
}
#[test]
fn real_codes_classify_correctly() {
for (code, want) in [
("10YDE-EON------1", EicType::Area),
("10YDE-RWENET---I", EicType::Area),
("10YDE-VE-------2", EicType::Area),
("10YDE-ENBW-----N", EicType::Area),
("10Y1001A1001A82H", EicType::Area),
("11XSUEDWESTSTRO8", EicType::Party),
("11XENERGIE2----H", EicType::Party),
("11XENAGISME----J", EicType::Party),
] {
let eic = EicCode::new(code).unwrap_or_else(|e| panic!("{code} should be valid: {e}"));
assert_eq!(eic.eic_type(), want, "{code}");
assert_eq!(eic.type_char(), want.as_char(), "{code}");
}
}
#[test]
fn shares_the_common_identifier_trait_surface() {
use std::borrow::Borrow;
let eic = EicCode::new("11XSUEDWESTSTRO8").unwrap();
assert!(eic.starts_with("11X"));
assert_eq!(eic.len(), 16);
let borrowed: &str = eic.borrow();
assert_eq!(borrowed, "11XSUEDWESTSTRO8");
assert_eq!(String::from(eic.clone()), "11XSUEDWESTSTRO8");
assert_eq!(EicCode::try_from("11XSUEDWESTSTRO8".to_string()), Ok(eic));
}
}