use super::checksum::{compute_numeric_id_from_base, validate_numeric_id};
use crate::error::IdentifierError;
const LEN: usize = 11;
const MIN_FIRST_DIGIT: u8 = 1;
#[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::malo_id_schema")
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(
value_type = String,
pattern = r"^[1-9][0-9]{10}$",
example = "41373559241",
description = "11-stellige BDEW Marktlokations-ID mit Prüfziffer nach dem Lok- und Waggon-Kennzeichnungsverfahren (11. Stelle)"
))]
pub struct MaloId(#[cfg_attr(feature = "validate", garde(custom(check_malo_id)))] Box<str>);
#[cfg(feature = "validate")]
fn check_malo_id(value: &str, _: &()) -> Result<(), garde::Error> {
validate_numeric_id(value, LEN, MIN_FIRST_DIGIT).map_err(garde::Error::from)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MaloVergabestelle {
Dvgw,
Bdew,
}
impl std::fmt::Display for MaloVergabestelle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Dvgw => "DVGW",
Self::Bdew => "BDEW",
})
}
}
impl MaloId {
#[must_use = "the validated identifier is returned; ignoring it discards the result"]
pub fn new(s: &str) -> Result<Self, IdentifierError> {
validate_numeric_id(s, LEN, MIN_FIRST_DIGIT)?;
Ok(Self(Box::from(s)))
}
pub fn from_base(base: &str) -> Result<Self, IdentifierError> {
let full = compute_numeric_id_from_base(base, LEN, MIN_FIRST_DIGIT)?;
Ok(Self(full.into_boxed_str()))
}
pub fn check_digit(base: &str) -> Result<u8, IdentifierError> {
let full = compute_numeric_id_from_base(base, LEN, MIN_FIRST_DIGIT)?;
Ok(full.as_bytes()[LEN - 1] - b'0')
}
#[must_use]
pub fn base(&self) -> &str {
&self.0[..LEN - 1]
}
#[must_use]
pub fn vergabestelle(&self) -> MaloVergabestelle {
match self.0.as_bytes()[0] {
b'1'..=b'3' => MaloVergabestelle::Dvgw,
_ => MaloVergabestelle::Bdew,
}
}
}
impl_identifier_traits!(MaloId, "an 11-digit Marktlokations-ID (BDEW check digit)");
#[cfg(test)]
mod tests {
use super::*;
use crate::error::LengthExpectation;
const EXTERNAL_VECTORS: &[(&str, &str)] =
&[("4137355924", "41373559241"), ("5123869678", "51238696781")];
#[test]
fn external_reference_vectors_validate() {
for &(base, full) in EXTERNAL_VECTORS {
let id = MaloId::new(full)
.unwrap_or_else(|e| panic!("{full} must be a valid MaLo-ID, got: {e}"));
assert_eq!(id.as_ref(), full);
assert_eq!(MaloId::from_base(base).unwrap(), id);
assert_eq!(id.base(), base);
}
}
#[test]
fn every_wrong_check_digit_is_rejected() {
for &(base, full) in EXTERNAL_VECTORS {
let correct = full.as_bytes()[10];
for d in b'0'..=b'9' {
if d == correct {
continue;
}
let candidate = format!("{base}{}", d as char);
assert!(
matches!(
MaloId::new(&candidate),
Err(IdentifierError::InvalidChecksum)
),
"{candidate} must be rejected as an invalid checksum"
);
}
}
}
#[test]
fn single_digit_typo_detection_matches_specification() {
let full = "41373559241";
for pos in 0..10 {
let original = i32::from(full.as_bytes()[pos] - b'0');
for d in b'0'..=b'9' {
if full.as_bytes()[pos] == d || (pos == 0 && d == b'0') {
continue;
}
let mut bytes = full.as_bytes().to_vec();
bytes[pos] = d;
let candidate = String::from_utf8(bytes).unwrap();
let delta = (i32::from(d - b'0') - original).rem_euclid(10);
let undetectable = pos % 2 == 1 && delta == 5;
assert_eq!(
MaloId::new(&candidate).is_err(),
!undetectable,
"{candidate} vs {full}: single-digit change at position {pos} \
(δ={delta}) — expected detected={}",
!undetectable
);
}
}
}
#[test]
fn adjacent_transpositions_are_caught() {
let full = "41373559241";
for pos in 0..9 {
let bytes = full.as_bytes();
if bytes[pos] == bytes[pos + 1] {
continue;
}
let mut swapped = bytes.to_vec();
swapped.swap(pos, pos + 1);
if swapped[0] == b'0' {
continue;
}
let candidate = String::from_utf8(swapped).unwrap();
assert!(
MaloId::new(&candidate).is_err(),
"{candidate} transposes positions {pos}/{} of {full} and must be rejected",
pos + 1
);
}
}
#[test]
fn first_digit_zero_is_rejected() {
let base = "0137355924";
assert!(matches!(
MaloId::from_base(base),
Err(IdentifierError::InvalidFormat { .. })
));
}
#[test]
fn vergabestelle_classification() {
for (base, expected) in [
("1137355924", MaloVergabestelle::Dvgw),
("3137355924", MaloVergabestelle::Dvgw),
("4137355924", MaloVergabestelle::Bdew),
("9137355924", MaloVergabestelle::Bdew),
] {
let id = MaloId::from_base(base).unwrap();
assert_eq!(id.vergabestelle(), expected, "for {base}");
}
}
#[test]
fn wrong_length_is_rejected() {
for (input, actual) in [("", 0usize), ("1234567890", 10), ("123456789012", 12)] {
assert!(matches!(
MaloId::new(input).unwrap_err(),
IdentifierError::InvalidLength {
expected: LengthExpectation::Exact(11),
actual: a,
} if a == actual
));
}
}
#[test]
fn non_digit_is_rejected_with_position() {
assert!(matches!(
MaloId::new("4137X559241").unwrap_err(),
IdentifierError::InvalidCharacter {
position: 4,
character: 'X'
}
));
assert!(matches!(
MaloId::new("4137 559241").unwrap_err(),
IdentifierError::InvalidCharacter {
position: 4,
character: ' '
}
));
assert!(matches!(
MaloId::new("-137355924").unwrap_err(),
IdentifierError::InvalidLength { .. }
));
}
#[test]
fn conversions_round_trip() {
let id = MaloId::new("41373559241").unwrap();
assert_eq!(id.to_string().parse::<MaloId>().unwrap(), id);
assert_eq!(MaloId::try_from("41373559241").unwrap(), id);
assert_eq!(MaloId::try_from(String::from("41373559241")).unwrap(), id);
assert_eq!(String::from(id.clone()), "41373559241");
assert_eq!(&*id, "41373559241");
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trip_and_rejection() {
let id = MaloId::new("41373559241").unwrap();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, r#""41373559241""#);
assert_eq!(serde_json::from_str::<MaloId>(&json).unwrap(), id);
assert!(serde_json::from_str::<MaloId>(r#""41373559242""#).is_err());
}
}