use std::cmp::Ordering;
use std::fmt;
use metering::ids::BdewCode;
use metering::interval::Sparte;
use time::OffsetDateTime;
use crate::error::{Error, Result};
const MAX_VERSION: u128 = 10u128.pow(20) - 1;
const MIN_DIGITS: u32 = 14;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Version(u128);
impl Version {
pub fn new(value: u128) -> Result<Self> {
if value > MAX_VERSION {
return Err(Error::encode(
"version",
format!("{value} exceeds Decimal128(20,0) range"),
));
}
Ok(Self(value))
}
pub const fn get(self) -> u128 {
self.0
}
pub fn mscons(value: u128) -> Result<Self> {
let version = Self::new(value)?;
if !version.is_well_formed() {
return Err(Error::encode(
"version",
format!(
"{value} has {} digits; MSCONS assigns at least {MIN_DIGITS}. \
Use Version::new to store a short version that has already been \
received — this constructor is for validating one at ingest",
value.checked_ilog10().map_or(1, |d| d + 1),
),
));
}
Ok(version)
}
pub fn arrival(recorded_at: OffsetDateTime) -> Result<Self> {
let millis = recorded_at.unix_timestamp_nanos() / 1_000_000;
let millis = u128::try_from(millis).map_err(|_| {
Error::encode(
"version",
format!(
"{recorded_at} predates the Unix epoch, so it has no arrival-derived \
version: versions are unsigned and must ascend"
),
)
})?;
Self::new(millis)
}
pub const fn is_well_formed(self) -> bool {
self.0 >= 10u128.pow(MIN_DIGITS - 1)
}
pub const fn to_i128(self) -> i128 {
self.0 as i128
}
pub fn from_i128(value: i128) -> Result<Self> {
u128::try_from(value)
.map_err(|_| Error::decode("version", format!("negative value {value}")))
.and_then(Self::new)
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VersionScope(String);
impl VersionScope {
pub fn new<O>(operator: O, year: i32, month: u8) -> Result<Self>
where
O: TryInto<BdewCode>,
O::Error: fmt::Display,
{
let operator = parse_operator(operator)?;
if !(1..=12).contains(&month) {
return Err(Error::config(format!("month {month} out of range 1..=12")));
}
Ok(Self(format!("{operator}:{year:04}-{month:02}")))
}
pub fn for_interval<O>(
operator: O,
interval_start: OffsetDateTime,
sparte: Sparte,
) -> Result<Self>
where
O: TryInto<BdewCode>,
O::Error: fmt::Display,
{
let month = crate::planner::balancing_month(interval_start, sparte);
Self::new(operator, month.year(), u8::from(month.month()))
}
pub fn period(&self) -> &str {
let at = self.0.find(':').expect("canonical form contains ':'");
&self.0[at + 1..]
}
pub fn operator(&self) -> BdewCode {
let at = self.0.find(':').expect("canonical form contains ':'");
self.0[..at].parse().expect("constructed from a BdewCode")
}
pub fn operator_has_bdew_check_digit(&self) -> bool {
self.operator().has_bdew_check_digit()
}
pub fn covers(&self, interval_start: OffsetDateTime, sparte: Sparte) -> bool {
let month = crate::planner::balancing_month(interval_start, sparte);
let period = self.period();
let Some((year, rest)) = period.split_once('-') else {
return false;
};
year.parse::<i32>() == Ok(month.year()) && rest.parse::<u8>() == Ok(u8::from(month.month()))
}
pub fn parse(s: impl Into<String>) -> Result<Self> {
let s = s.into();
let malformed = || {
Error::decode(
"version_scope",
format!(
"{s:?} is not a canonical version scope — it must be \
<operator>:<YYYY-MM>, with a 13-digit Marktpartner-ID as the \
operator and a month in 01..=12"
),
)
};
let Some((operator, period)) = s.split_once(':') else {
return Err(malformed());
};
let Some((year, month)) = period.split_once('-') else {
return Err(malformed());
};
let canonical = operator
.parse::<BdewCode>()
.is_ok_and(|code| code.as_str() == operator);
if !canonical
|| year.len() != 4
|| month.len() != 2
|| year.parse::<i32>().is_err()
|| !month.parse::<u8>().is_ok_and(|m| (1..=12).contains(&m))
{
return Err(malformed());
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for VersionScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn parse_operator<O>(operator: O) -> Result<BdewCode>
where
O: TryInto<BdewCode>,
O::Error: fmt::Display,
{
operator.try_into().map_err(|e| {
Error::config(format!(
"a version scope's operator is the network operator's Marktpartner-ID, \
as MSCONS carries it in NAD+MS: {e}. A version is only comparable within \
the (operator, month) that issued it, so a wrong one is a scope of its \
own — the correction never supersedes, and both rows survive into the \
resolved view"
))
})
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ScopedVersion {
scope: VersionScope,
version: Version,
}
impl ScopedVersion {
pub const fn new(scope: VersionScope, version: Version) -> Self {
Self { scope, version }
}
pub const fn scope(&self) -> &VersionScope {
&self.scope
}
pub const fn version(&self) -> Version {
self.version
}
pub fn try_cmp(&self, other: &Self) -> Result<Ordering> {
if self.scope != other.scope {
return Err(Error::VersionScopeMismatch {
left: self.scope.0.clone(),
right: other.scope.0.clone(),
});
}
Ok(self.version.0.cmp(&other.version.0))
}
pub fn supersedes(&self, other: &Self) -> Result<bool> {
Ok(self.try_cmp(other)? == Ordering::Greater)
}
pub fn next(&self) -> Result<Self> {
Ok(Self::new(
self.scope.clone(),
Version::new(self.version.get().saturating_add(1))?,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::Duration;
const OPERATOR: &str = "9900000000001";
const OTHER_OPERATOR: &str = "9900000000002";
fn scope(op: &str, y: i32, m: u8) -> VersionScope {
VersionScope::new(op, y, m).unwrap()
}
#[test]
fn version_round_trips_through_storage_encoding() {
let v = Version::new(20_260_727_000_001).unwrap();
assert_eq!(Version::from_i128(v.to_i128()).unwrap(), v);
}
#[test]
fn version_rejects_values_beyond_decimal128_20_0() {
assert!(Version::new(MAX_VERSION).is_ok());
assert!(Version::new(MAX_VERSION + 1).is_err());
}
#[test]
fn version_rejects_negative_on_decode() {
assert!(Version::from_i128(-1).is_err());
}
#[test]
fn the_strict_constructor_refuses_a_short_version() {
let err = Version::mscons(42).unwrap_err().to_string();
assert!(err.contains("14"), "{err}");
assert!(
err.contains("Version::new"),
"the message must name the permissive path for data already received: {err}"
);
assert!(Version::mscons(20_260_727_000_001).is_ok());
assert!(Version::mscons(u128::MAX).is_err());
}
#[test]
fn an_arrival_version_always_sorts_below_a_stated_one() {
use time::macros::datetime;
let floor = Version::mscons(10_000_000_000_000).unwrap();
for at in [
datetime!(1970-01-02 00:00 UTC),
datetime!(2026-08-25 06:00:00.123 UTC),
datetime!(2100-01-01 00:00 UTC),
datetime!(2286-11-20 00:00 UTC),
] {
let v = Version::arrival(at).unwrap();
assert!(v.get() < floor.get(), "{at}: {v} is not below {floor}");
assert!(!v.is_well_formed(), "{at}: must not look MSCONS-issued");
}
}
#[test]
fn an_arrival_version_is_sub_second() {
use time::macros::datetime;
let a = Version::arrival(datetime!(2026-08-25 06:00:00.001 UTC)).unwrap();
let b = Version::arrival(datetime!(2026-08-25 06:00:00.002 UTC)).unwrap();
assert!(b.get() > a.get());
assert_eq!(b.get() - a.get(), 1);
}
#[test]
fn arrival_versions_ascend_with_arrival() {
use time::macros::datetime;
let mut previous = Version::arrival(datetime!(2026-01-01 00:00 UTC)).unwrap();
for hours in 1..48 {
let next =
Version::arrival(datetime!(2026-01-01 00:00 UTC) + Duration::hours(hours)).unwrap();
assert!(next.get() > previous.get());
previous = next;
}
}
#[test]
fn a_pre_epoch_instant_has_no_arrival_version() {
use time::macros::datetime;
let err = Version::arrival(datetime!(1969-12-31 23:59 UTC))
.unwrap_err()
.to_string();
assert!(err.contains("epoch"), "{err}");
}
#[test]
fn the_permissive_constructor_still_accepts_what_arrived() {
assert!(Version::new(42).is_ok());
assert!(!Version::new(42).unwrap().is_well_formed());
}
#[test]
fn well_formedness_tracks_the_14_digit_rule() {
assert!(Version::new(10_000_000_000_000).unwrap().is_well_formed());
assert!(!Version::new(9_999_999_999_999).unwrap().is_well_formed());
}
#[test]
fn short_versions_are_stored_and_still_order() {
let s = scope(OPERATOR, 2026, 7);
let a = ScopedVersion::new(s.clone(), Version::new(1).unwrap());
let b = ScopedVersion::new(s, Version::new(2).unwrap());
assert!(b.supersedes(&a).unwrap());
}
#[test]
fn versions_order_within_a_scope() {
let s = scope(OPERATOR, 2026, 7);
let older = ScopedVersion::new(s.clone(), Version::new(20_260_701_000_001).unwrap());
let newer = ScopedVersion::new(s, Version::new(20_260_715_000_002).unwrap());
assert_eq!(older.try_cmp(&newer).unwrap(), Ordering::Less);
assert!(newer.supersedes(&older).unwrap());
assert!(!older.supersedes(&newer).unwrap());
}
#[test]
fn the_successor_supersedes_and_keeps_the_scope() {
let stored = ScopedVersion::new(
scope(OPERATOR, 2026, 7),
Version::new(20_260_715_000_002).unwrap(),
);
let next = stored.next().unwrap();
assert!(next.supersedes(&stored).unwrap());
assert_eq!(next.scope(), stored.scope());
assert_eq!(next.version().get(), stored.version().get() + 1);
}
#[test]
fn the_successor_refuses_to_leave_the_storage_range() {
let at_ceiling =
ScopedVersion::new(scope(OPERATOR, 2026, 7), Version::new(MAX_VERSION).unwrap());
assert!(at_ceiling.next().is_err());
}
#[test]
fn versions_do_not_compare_across_operators() {
let a = ScopedVersion::new(scope(OPERATOR, 2026, 7), Version::new(5).unwrap());
let b = ScopedVersion::new(scope(OTHER_OPERATOR, 2026, 7), Version::new(9).unwrap());
assert!(matches!(
a.try_cmp(&b),
Err(Error::VersionScopeMismatch { .. })
));
}
#[test]
fn versions_do_not_compare_across_months() {
let a = ScopedVersion::new(scope(OPERATOR, 2026, 7), Version::new(5).unwrap());
let b = ScopedVersion::new(scope(OPERATOR, 2026, 8), Version::new(9).unwrap());
assert!(a.try_cmp(&b).is_err());
}
#[test]
fn a_scope_derived_from_an_interval_uses_the_local_month() {
use time::macros::datetime;
let july =
VersionScope::for_interval(OPERATOR, datetime!(2026-07-31 20:00 UTC), Sparte::Strom)
.unwrap();
let august =
VersionScope::for_interval(OPERATOR, datetime!(2026-07-31 23:00 UTC), Sparte::Strom)
.unwrap();
assert_eq!(july.period(), "2026-07");
assert_eq!(august.period(), "2026-08");
}
#[test]
fn the_gas_bilanzierungsmonat_is_cut_at_the_gastag_boundary() {
use time::macros::datetime;
let early = datetime!(2026-03-01 1:00 UTC);
assert_eq!(
VersionScope::for_interval(OPERATOR, early, Sparte::Strom)
.unwrap()
.period(),
"2026-03"
);
assert_eq!(
VersionScope::for_interval(OPERATOR, early, Sparte::Gas)
.unwrap()
.period(),
"2026-02"
);
let later = datetime!(2026-03-01 6:00 UTC);
for sparte in [Sparte::Strom, Sparte::Gas] {
assert_eq!(
VersionScope::for_interval(OPERATOR, later, sparte)
.unwrap()
.period(),
"2026-03"
);
}
}
#[test]
fn a_correctly_scoped_gas_delivery_is_accepted() {
use time::macros::datetime;
let early = datetime!(2026-03-01 1:00 UTC);
let correct = VersionScope::for_interval(OPERATOR, early, Sparte::Gas).unwrap();
assert!(correct.covers(early, Sparte::Gas));
let calendar_month = VersionScope::new(OPERATOR, 2026, 3).unwrap();
assert!(
!calendar_month.covers(early, Sparte::Gas),
"the calendar month is not this interval's gas Bilanzierungsmonat"
);
assert!(calendar_month.covers(early, Sparte::Strom));
}
#[test]
fn every_version_of_one_interval_derives_the_same_scope() {
use time::macros::datetime;
let interval = datetime!(2026-07-20 06:00 UTC);
for sparte in [Sparte::Strom, Sparte::Gas] {
let original = VersionScope::for_interval(OPERATOR, interval, sparte).unwrap();
let correction = VersionScope::for_interval(OPERATOR, interval, sparte).unwrap();
assert_eq!(original, correction);
}
}
#[test]
fn a_derived_scope_always_covers_the_interval_it_came_from() {
use time::macros::datetime;
let mut at = datetime!(2026-01-01 00:00 UTC);
let end = datetime!(2027-01-01 00:00 UTC);
while at < end {
for sparte in [Sparte::Strom, Sparte::Gas, Sparte::Waerme, Sparte::Wasser] {
let scope = VersionScope::for_interval(OPERATOR, at, sparte).unwrap();
assert!(scope.covers(at, sparte), "{at} {sparte} {scope}");
}
at += time::Duration::hours(5);
}
}
#[test]
fn covers_accepts_only_intervals_in_the_scope_month() {
use time::macros::datetime;
let july =
VersionScope::for_interval(OPERATOR, datetime!(2026-07-20 00:00 UTC), Sparte::Strom)
.unwrap();
assert!(july.covers(datetime!(2026-07-01 00:00 UTC), Sparte::Strom));
assert!(july.covers(datetime!(2026-07-31 20:00 UTC), Sparte::Strom));
assert!(!july.covers(datetime!(2026-08-01 00:00 UTC), Sparte::Strom));
assert!(!july.covers(datetime!(2026-07-31 23:00 UTC), Sparte::Strom));
}
#[test]
fn operator_and_period_split_the_canonical_form() {
let s = scope(OPERATOR, 2026, 3);
assert_eq!(s.operator().as_str(), OPERATOR);
assert_eq!(s.period(), "2026-03");
}
#[test]
fn scope_round_trips_through_canonical_form() {
let s = scope(OPERATOR, 2026, 3);
assert_eq!(s.as_str(), "9900000000001:2026-03");
assert_eq!(VersionScope::parse(s.as_str()).unwrap(), s);
}
#[test]
fn parse_accepts_only_what_new_would_have_produced() {
for bad in [
"9900000000001:2026-13", "9900000000001:2026-99",
"9900000000001:2026-00",
"9900000000001:20x6-03", "a:b:2026-03", ":2026-03", "9900000000001:2026-3", "9900000000001:202-003", "9900000000001", "99:2026-03", "990000000000:2026-03", "99000000000012:2026-03", " 9900000000001:2026-03",
"9900000000001 :2026-03",
] {
assert!(VersionScope::parse(bad).is_err(), "{bad:?} must not parse");
}
for month in 1..=12 {
let s = scope(OPERATOR, 2026, month);
assert_eq!(VersionScope::parse(s.as_str()).unwrap(), s);
}
}
#[test]
fn the_operator_is_the_half_postgres_reads_back() {
let s = scope(OPERATOR, 2026, 3);
assert_eq!(s.operator().as_str(), s.as_str().split(':').next().unwrap());
}
#[test]
fn the_operator_must_be_a_marktpartner_id() {
for bad in [
"99", "bad:operator", "990000000000", "99000000000012", "99000000000x1", "",
] {
assert!(
VersionScope::new(bad, 2026, 7).is_err(),
"{bad:?} is not a Marktpartner-ID"
);
}
let err = VersionScope::new("99", 2026, 7).unwrap_err().to_string();
assert!(
err.contains("13-digit"),
"metering's own shape message: {err}"
);
assert!(
err.contains("NAD+MS"),
"the message must name where the value comes from: {err}"
);
assert!(
err.contains("supersedes"),
"and what a wrong one costs, since the failure is otherwise invisible: {err}"
);
}
#[test]
fn a_gs1_gln_is_stored_and_flagged_rather_than_refused() {
let digits = "990098765432";
let check = metering::ids::BdewCode::compute_check_digit(digits).expect("twelve digits");
let consistent = VersionScope::new(&*format!("{digits}{check}"), 2026, 7).unwrap();
assert!(consistent.operator_has_bdew_check_digit());
let wrong_check = (check + 1) % 10;
let inconsistent = VersionScope::new(&*format!("{digits}{wrong_check}"), 2026, 7).unwrap();
assert!(!inconsistent.operator_has_bdew_check_digit());
assert!(
VersionScope::parse(inconsistent.as_str()).is_ok(),
"stored and readable back: the flag is advisory, not a gate"
);
}
#[test]
fn scope_rejects_out_of_range_month() {
assert!(VersionScope::new(OPERATOR, 2026, 0).is_err());
assert!(VersionScope::new(OPERATOR, 2026, 13).is_err());
}
}