knx-catalog 0.0.8

Reviewed KNX datapoint-type catalogue as const data
Documentation
//! The datapoint type identifier: the one identifier type, its strict
//! text grammar, and its canonical rendering.
//!
//! Text is meant to enter exactly once: the resolution stage that
//! fronts the foundation calls [`DptId::parse`], and every layer below
//! it takes [`DptId`] by value - no such layer accepts text. The
//! caller's spelling is neither identity nor state: a diagnostic that
//! renders an identifier renders the canonical form.

use core::fmt;
use core::str::FromStr;

/// A datapoint type identifier: a 16-bit main number and a 16-bit
/// subnumber.
///
/// The KNX source defines both components as 16-bit numbers, so each
/// ranges over `0..=65535`, and subnumber 0 is a real, registered value.
/// Identity is structural equality on the pair; ordering is by main
/// number, then subnumber.
///
/// The main number groups subtypes that generally share format and
/// encoding, and the subnumber selects range, unit and semantics. That
/// grouping is a stated intent, not a structural guarantee: the
/// specification itself defines main numbers whose subtypes carry
/// different layouts, so layout facts always come from the subtype row,
/// never from the main number alone.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct DptId {
    /// The main number.
    pub main: u16,
    /// The subnumber.
    pub sub: u16,
}

impl DptId {
    /// Creates an identifier from its two components.
    pub const fn new(main: u16, sub: u16) -> Self {
        Self { main, sub }
    }

    /// Parses the canonical text grammar, strictly.
    ///
    /// Accepted: 1 to 5 ASCII digits, one `.`, 1 to 5 ASCII digits, each
    /// side parsing into `u16`. Leading zeros are permitted on both
    /// sides - the canonical rendering itself pads, so any parser of
    /// canonical output must accept them. Everything else is rejected:
    /// no whitespace anywhere, no sign, no second dot, no non-ASCII
    /// digit, no overflow.
    ///
    /// A syntactically valid identifier is not necessarily catalogued;
    /// parse failure, not-in-the-catalogue and
    /// catalogued-but-not-implemented are three distinct conditions and
    /// this function reports only the first.
    pub fn parse(text: &str) -> Result<Self, ParseIdError> {
        let Some((main, sub)) = text.split_once('.') else {
            return Err(ParseIdError::NoSeparator);
        };
        let main = parse_component(main.as_bytes())?;
        let sub = parse_component(sub.as_bytes())?;
        Ok(Self { main, sub })
    }
}

/// Parses one identifier component: 1 to 5 ASCII digits into `u16`.
///
/// Cause order is fixed and documented: an empty component reports
/// `EmptyComponent`; any non-digit byte (including a second `.`)
/// reports `NonDigitByte`; more than 5 digits reports `TooManyDigits`;
/// a 5-digit value above `u16::MAX` reports `Overflow`.
fn parse_component(bytes: &[u8]) -> Result<u16, ParseIdError> {
    if bytes.is_empty() {
        return Err(ParseIdError::EmptyComponent);
    }
    if !bytes.iter().all(u8::is_ascii_digit) {
        return Err(ParseIdError::NonDigitByte);
    }
    if bytes.len() > 5 {
        return Err(ParseIdError::TooManyDigits);
    }
    let mut value: u32 = 0;
    for &byte in bytes {
        value = value * 10 + u32::from(byte - b'0');
    }
    u16::try_from(value).map_err(|_| ParseIdError::Overflow)
}

impl FromStr for DptId {
    type Err = ParseIdError;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        Self::parse(text)
    }
}

/// Canonical rendering: the main number unpadded and the subnumber
/// zero-padded to at least 3 digits, never truncated - 4 digits print
/// as 4.
impl fmt::Display for DptId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}.{:03}", self.main, self.sub)
    }
}

/// Why an identifier string failed to parse.
///
/// The payload is the structured cause alone: no echo of the caller's
/// string and no position. A batch caller that needs positions uses a
/// diagnostic parse of its own; the error type stays allocation-free.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ParseIdError {
    /// The text contains no `.` separator.
    NoSeparator,
    /// A component is empty.
    EmptyComponent,
    /// A component contains a byte that is not an ASCII digit; a second
    /// `.` reports this cause, as does any sign, whitespace or non-ASCII
    /// digit.
    NonDigitByte,
    /// A component has more than 5 digits.
    TooManyDigits,
    /// A component parses to a value above `u16::MAX`.
    Overflow,
}

impl fmt::Display for ParseIdError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::NoSeparator => "identifier has no `.` separator",
            Self::EmptyComponent => "identifier component is empty",
            Self::NonDigitByte => "identifier component contains a non-digit byte",
            Self::TooManyDigits => "identifier component has more than 5 digits",
            Self::Overflow => "identifier component exceeds 65535",
        })
    }
}

impl core::error::Error for ParseIdError {}