use crate::error::IdentifierError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ObisComponents {
pub a: Option<u8>,
pub b: Option<u8>,
pub c: u8,
pub d: u8,
pub e: Option<u8>,
pub f: Option<u8>,
}
fn parse_group(s: &str) -> Option<(u8, &str)> {
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if end == 0 {
return None;
}
let n = s[..end].parse::<u8>().ok()?;
Some((n, &s[end..]))
}
fn parse_whole_group(s: &str) -> Option<u8> {
match parse_group(s) {
Some((n, "")) => Some(n),
_ => None,
}
}
fn validate_and_parse(s: &str) -> Result<ObisComponents, IdentifierError> {
if s.is_empty() {
return Err(IdentifierError::InvalidFormat {
description: "OBIS code must not be empty".into(),
});
}
fn bad_group(group: char) -> IdentifierError {
IdentifierError::InvalidFormat {
description: format!(
"{group} component must be a single octet (0-255) per IEC 62056-61"
)
.into(),
}
}
let (s, f) = if let Some(idx) = s.rfind(['*', '&']) {
let f_val = parse_whole_group(&s[idx + 1..]).ok_or_else(|| bad_group('F'))?;
(&s[..idx], Some(f_val))
} else {
(s, None)
};
let (s, a, b) = if let Some(colon_pos) = s.find(':') {
let prefix = &s[..colon_pos];
let rest = &s[colon_pos + 1..];
if let Some(dash_pos) = prefix.find('-') {
let a = parse_whole_group(&prefix[..dash_pos]).ok_or_else(|| bad_group('A'))?;
let b = parse_whole_group(&prefix[dash_pos + 1..]).ok_or_else(|| bad_group('B'))?;
(rest, Some(a), Some(b))
} else {
let a = parse_whole_group(prefix).ok_or_else(|| bad_group('A'))?;
(rest, Some(a), None)
}
} else {
(s, None, None)
};
let (c, rest) = parse_group(s).ok_or_else(|| bad_group('C'))?;
if !rest.starts_with('.') {
return Err(IdentifierError::InvalidFormat {
description: "expected '.' separator between C and D".into(),
});
}
let rest = &rest[1..];
let (d, rest) = parse_group(rest).ok_or_else(|| bad_group('D'))?;
let (e, rest_after_e) = if let Some(after_dot) = rest.strip_prefix('.') {
let (e_val, remainder) = parse_group(after_dot).ok_or_else(|| bad_group('E'))?;
(Some(e_val), remainder)
} else {
(None, rest)
};
if !rest_after_e.is_empty() {
return Err(IdentifierError::InvalidFormat {
description: "unexpected trailing characters after OBIS code".into(),
});
}
Ok(ObisComponents { a, b, c, d, e, f })
}
impl ObisComponents {
fn render(&self, include_f: bool) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(32);
match (self.a, self.b) {
(Some(a), Some(b)) => {
let _ = write!(out, "{a}-{b}:");
}
(Some(a), None) => {
let _ = write!(out, "{a}:");
}
(None, _) => {}
}
let _ = write!(out, "{}.{}", self.c, self.d);
if let Some(e) = self.e {
let _ = write!(out, ".{e}");
}
if include_f {
if let Some(f) = self.f {
let _ = write!(out, "*{f}");
}
}
out
}
}
#[derive(Debug, Clone)]
#[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::obis_code_schema")
)]
pub struct ObisCode {
#[cfg_attr(feature = "validate", garde(custom(check_obis_code)))]
canonical: Box<str>,
#[cfg_attr(feature = "validate", garde(skip))]
components: ObisComponents,
}
impl PartialEq for ObisCode {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.canonical == other.canonical
}
}
impl Eq for ObisCode {}
impl std::hash::Hash for ObisCode {
#[inline]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.canonical.hash(state);
}
}
impl PartialOrd for ObisCode {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ObisCode {
#[inline]
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.canonical.cmp(&other.canonical)
}
}
#[cfg(feature = "validate")]
fn check_obis_code(value: &str, _: &()) -> Result<(), garde::Error> {
validate_and_parse(value)
.map(|_| ())
.map_err(garde::Error::from)
}
impl ObisCode {
#[must_use = "the validated identifier is returned; ignoring it discards the result"]
pub fn new(s: &str) -> Result<Self, IdentifierError> {
let components = validate_and_parse(s)?;
Ok(Self {
canonical: components.render(true).into_boxed_str(),
components,
})
}
#[must_use]
pub fn components(&self) -> ObisComponents {
self.components
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.canonical
}
#[must_use]
pub fn to_pia_string(&self) -> String {
self.components.render(false)
}
}
impl_identifier_traits!(
ObisCode,
"an OBIS code string (e.g. \"1-0:1.8.0*255\")",
field = canonical
);
#[cfg(feature = "utoipa")]
impl utoipa::PartialSchema for ObisCode {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.pattern(Some(OBIS_PATTERN))
.description(Some(
"OBIS-Kennzahl nach IEC 62056-61: [A-B:]C.D[.E][*F]. \
Wird kanonisiert gespeichert (führende Nullen entfallen, '&' wird zu '*').",
))
.examples(["1-0:1.8.0*255"])
.into()
}
}
#[cfg(feature = "utoipa")]
impl utoipa::ToSchema for ObisCode {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("ObisCode")
}
}
#[cfg(any(feature = "schemars", feature = "utoipa"))]
pub(crate) const OBIS_PATTERN: &str = r"^(?:\d+(?:-\d+)?:)?\d+\.\d+(?:\.\d+)?(?:[*&]\d+)?$";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn c_dot_d_only() {
let c = ObisCode::new("1.8").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(None, None, 1, 8, None, None)
);
}
#[test]
fn c_dot_d_dot_e() {
let c = ObisCode::new("1.8.1").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(None, None, 1, 8, Some(1), None)
);
}
#[test]
fn a_b_colon_c_dot_d_dot_e() {
let c = ObisCode::new("1-0:1.8.1").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(Some(1), Some(0), 1, 8, Some(1), None)
);
}
#[test]
fn with_f_component_star() {
let c = ObisCode::new("1-0:1.8.0*255").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(Some(1), Some(0), 1, 8, Some(0), Some(255))
);
}
#[test]
fn with_f_component_ampersand() {
let c = ObisCode::new("1-0:1.8.0&255").unwrap();
let p = c.components();
assert_eq!(p.f, Some(255));
}
#[test]
fn a_colon_without_b() {
let c = ObisCode::new("1:1.8.1").unwrap();
let p = c.components();
assert_eq!((p.a, p.b, p.c, p.d, p.e), (Some(1), None, 1, 8, Some(1)));
}
#[test]
fn c_zero_is_valid() {
let c = ObisCode::new("0-0:0.0.0*0").unwrap();
let p = c.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(Some(0), Some(0), 0, 0, Some(0), Some(0))
);
let c2 = ObisCode::new("0-0:1.0.0*0").unwrap();
let p2 = c2.components();
assert_eq!(
(p2.a, p2.b, p2.c, p2.d, p2.f),
(Some(0), Some(0), 1, 0, Some(0))
);
}
#[test]
fn display_preserves_input() {
let input = "1-0:1.8.1";
assert_eq!(ObisCode::new(input).unwrap().to_string(), input);
}
#[test]
fn to_pia_drops_f() {
let c = ObisCode::new("1-0:1.8.0*255").unwrap();
assert_eq!(c.to_pia_string(), "1-0:1.8.0");
}
#[test]
fn canonical_form_preserves_f() {
let c = ObisCode::new("1-0:1.8.0*255").unwrap();
assert_eq!(c.as_str(), "1-0:1.8.0*255");
}
#[test]
fn pia_and_canonical_agree_when_there_is_no_f() {
let s = "1-0:1.8.1";
let c = ObisCode::new(s).unwrap();
assert_eq!(c.to_pia_string(), s);
assert_eq!(c.as_str(), s);
}
#[test]
fn every_string_view_agrees() {
let c = ObisCode::new("01-00:01.08.00&255").unwrap();
assert_eq!(c.as_str(), "1-0:1.8.0*255");
assert_eq!(c.as_ref(), c.as_str());
assert_eq!(c.to_string(), c.as_str());
assert_eq!(&*c, c.as_str());
}
#[test]
fn round_trip() {
let s = "1-0:1.8.0*255";
let c = s.parse::<ObisCode>().unwrap();
assert_eq!(c.to_string(), s);
}
#[test]
fn equal_values_are_equal_regardless_of_spelling() {
use std::collections::HashSet;
for (a, b) in [
("1.8.1&255", "1.8.1*255"), ("01-00:01.08.00", "1-0:1.8.0"), ("0001.0008", "1.8"), ("01:01.08.01*0255", "1:1.8.1*255"), ] {
let (x, y) = (ObisCode::new(a).unwrap(), ObisCode::new(b).unwrap());
assert_eq!(x, y, "{a} vs {b}");
assert_eq!(x.as_str(), y.as_str(), "{a} vs {b}");
let set: HashSet<_> = [x, y].into_iter().collect();
assert_eq!(set.len(), 1, "{a} and {b} must hash alike");
}
}
#[test]
fn canonicalisation_is_idempotent() {
for input in [
"1-0:1.8.0*255",
"01-00:01.08.00&0255",
"1:1.8",
"0.0",
"1.8.1",
] {
let once = ObisCode::new(input).unwrap();
let twice = ObisCode::new(once.as_str()).unwrap();
assert_eq!(once, twice, "{input}");
assert_eq!(once.as_str(), twice.as_str(), "{input}");
}
}
#[test]
fn components_and_canonical_string_agree() {
for input in ["1-0:1.8.0*255", "01:01.08", "0000.0000", "1.8.1"] {
let code = ObisCode::new(input).unwrap();
let reparsed = ObisCode::new(code.as_str()).unwrap();
assert_eq!(code.components(), reparsed.components(), "{input}");
}
}
#[test]
fn components_is_a_stable_accessor() {
let c = ObisCode::new("1-0:1.8.0*255").unwrap();
assert_eq!(c.components(), c.components());
}
#[test]
fn empty_string_fails() {
assert!(matches!(
ObisCode::new("").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn missing_d_component_fails() {
assert!(matches!(
ObisCode::new("1.").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn missing_c_component_fails() {
assert!(matches!(
ObisCode::new(".8").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn trailing_garbage_fails() {
assert!(matches!(
ObisCode::new("1.8.1.2").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn non_numeric_c_fails() {
assert!(matches!(
ObisCode::new("A.8").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn non_numeric_f_fails() {
assert!(matches!(
ObisCode::new("1.8*abc").unwrap_err(),
IdentifierError::InvalidFormat { .. }
));
}
#[test]
fn value_groups_are_octets() {
let max = ObisCode::new("255-255:255.255.255*255").unwrap();
let p = max.components();
assert_eq!(
(p.a, p.b, p.c, p.d, p.e, p.f),
(Some(255), Some(255), 255, 255, Some(255), Some(255))
);
for over in [
"256-0:1.8.0",
"1-256:1.8.0",
"1-0:256.8.0",
"1-0:1.256.0",
"1-0:1.8.256",
"1-0:1.8.0*256",
] {
assert!(
matches!(
ObisCode::new(over),
Err(IdentifierError::InvalidFormat { .. })
),
"{over} must be rejected: OBIS value groups are single octets"
);
}
}
#[test]
fn octet_overflow_names_the_group() {
for (input, group) in [
("256-0:1.8", 'A'),
("1-256:1.8", 'B'),
("300.8", 'C'),
("1.300", 'D'),
("1.8.300", 'E'),
("1.8*300", 'F'),
] {
let msg = ObisCode::new(input).unwrap_err().to_string();
assert!(
msg.contains(group),
"error for {input:?} should name group {group}: {msg}"
);
}
}
#[test]
fn borrowing_as_str_finds_the_same_entry() {
use std::collections::HashMap;
let mut by_code: HashMap<ObisCode, u32> = HashMap::new();
by_code.insert(ObisCode::new("1-0:1.8.0").unwrap(), 7);
assert_eq!(by_code.get("1-0:1.8.0"), Some(&7));
assert_eq!(by_code.get("01-00:01.08.00"), None);
}
#[test]
fn codes_order_totally_by_canonical_string() {
use std::collections::BTreeMap;
let mut m: BTreeMap<ObisCode, u32> = BTreeMap::new();
for s in ["1-0:2.8.0", "1-0:1.8.0", "1-0:1.8.1"] {
m.insert(ObisCode::new(s).unwrap(), 0);
}
assert_eq!(
m.keys().map(ObisCode::as_str).collect::<Vec<_>>(),
["1-0:1.8.0", "1-0:1.8.1", "1-0:2.8.0"]
);
}
#[test]
fn leading_zeros_do_not_overflow_the_octet() {
assert_eq!(
ObisCode::new("0001-0000:0001.0008.0000*0255")
.unwrap()
.as_str(),
"1-0:1.8.0*255"
);
}
}