edi-energy 0.19.0

EDI@Energy EDIFACT parser and validator for the German energy market
Documentation
use crate::report::EdiEnergyReport;

// ── Sanitization helper ───────────────────────────────────────────────────────

/// Sanitize an untrusted EDIFACT type-code string for safe inclusion in error
/// fields, tracing spans, and diagnostic messages.
///
/// Valid BDEW/EDIFACT type codes are ≤ 16 ASCII alphanumeric characters plus
/// `.`.  Characters outside that set are replaced with `?` to neutralize
/// ANSI escape sequences and other log-injection payloads while keeping the
/// value informative.
/// Truncation counts **characters**, not bytes: the value comes straight off
/// the wire, and `&s[..16]` panics when byte 16 lands inside a multi-byte
/// character — turning a malformed message into a downed parser.
///
/// Gated: with every message-type feature off there is no dispatch path that can
/// produce an unknown type code, so nothing calls this.
#[cfg(any_message)]
pub(crate) fn sanitize_code(s: &str) -> String {
    const MAX_CHARS: usize = 16;
    s.chars()
        .take(MAX_CHARS)
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '.' {
                c
            } else {
                '?'
            }
        })
        .collect()
}

#[cfg(all(test, any_message))]
mod sanitize_tests {
    use super::sanitize_code;

    #[test]
    fn truncates_on_character_boundaries() {
        assert_eq!(sanitize_code(&"ü".repeat(17)), "?".repeat(16));
    }

    #[test]
    fn neutralises_escape_sequences_and_keeps_plain_codes() {
        assert_eq!(sanitize_code("UTILMD"), "UTILMD");
        assert_eq!(sanitize_code("5.5.3a"), "5.5.3a");
        assert_eq!(sanitize_code("A\u{1b}[31mB"), "A??31mB");
    }
}

// ── ProfileError ──────────────────────────────────────────────────────────────

/// Errors that arise from a malformed or incomplete profile configuration.
///
/// These are distinct from message-validation errors ([`Error::Validation`]).
/// A `ProfileError` signals that the *profile itself* is incorrect — e.g. a
/// required field is missing from a `profiles/**/*.json` file.  Message-level
/// errors are represented by [`Error::Validation`].
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
    /// A mandatory field is absent from the profile data.
    ///
    /// This should never occur for a profile imported by
    /// `cargo xtask import-profiles`, but it can arise when building profiles
    /// programmatically.
    #[error("profile field `{field}` is mandatory but was not provided")]
    MissingField {
        /// The name of the missing field, e.g. `"message_type"` or `"release"`.
        ///
        /// `Cow<'static, str>` allows both static literals (zero-cost) and
        /// dynamically computed field names (owned `String`).
        field: std::borrow::Cow<'static, str>,
    },

    /// A field value is present but does not meet the profile's constraints.
    #[error("profile field `{field}` has invalid value {value:?}: {reason}")]
    InvalidField {
        /// The name of the invalid field.
        field: &'static str,
        /// The rejected value.
        value: String,
        /// Human-readable explanation.
        reason: String,
    },
}

// ── Error ─────────────────────────────────────────────────────────────────────

/// All errors that can be produced by `edi-energy`.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The underlying EDIFACT parser rejected the input.
    #[error("EDIFACT parse error: {0}")]
    Parse(#[from] edifact_rs::EdifactError),

    /// Writing an EDIFACT structure failed (envelope or segment serialization).
    ///
    /// Distinct from [`Parse`](Self::Parse): the input was accepted, but the
    /// output could not be rendered — e.g. a value outside the UNOC character
    /// set or beyond a data element's length bound.
    #[error("EDIFACT serialization error: {0}")]
    Serialize(String),

    /// The message type is known but the corresponding Cargo feature is not compiled in.
    ///
    /// Enable the `feature` Cargo feature for this crate to parse `message_type` messages.
    #[error("message type {message_type:?} requires the disabled `{feature}` Cargo feature")]
    FeatureNotEnabled {
        /// The EDIFACT message type code, e.g. `"UTILMD"`.
        message_type: String,
        /// The Cargo feature name that must be enabled, e.g. `"utilmd"`.
        feature: String,
    },

    /// The message type code from UNH is not recognised by this crate at all.
    ///
    /// The raw code is not included in `Display` output to avoid GDPR-sensitive
    /// data leaking into operator logs.  Access the code via the `raw_code` field
    /// when needed for diagnostic purposes.
    ///
    /// The `raw_code` value is sanitized at construction: characters outside
    /// ASCII alphanumeric and `.` are replaced with `?` so log-injection sequences
    /// are neutralized before the value enters any tracing span or log record.
    #[error("unknown EDIFACT message type code (check UNH DE 0065 element 1 component 0)")]
    UnknownMessageType {
        /// The sanitized UNH type code.  Not emitted in `Display`; available for debugging.
        raw_code: String,
    },

    /// A mandatory EDIFACT segment is absent from the message.
    #[error("required segment {0} is missing")]
    MissingSegment(&'static str),

    /// A segment was found but its content is structurally invalid.
    #[error("malformed segment {0}")]
    MalformedSegment(&'static str),

    /// The BGM document-identifier field (Pruefidentifikator) was not present.
    #[error("Pruefidentifikator not found in BGM segment")]
    MissingPruefidentifikator,

    /// A parsed Pruefidentifikator is outside the valid 5-digit range (10000–99999).
    ///
    /// The invalid numeric value is carried as context for diagnostic messages.
    #[error("invalid Pruefidentifikator {0}: must be a 5-digit code in the range 10000–99999")]
    InvalidPruefidentifikatorRange(u32),

    /// The Pruefidentifikator field is not a decimal integer at all.
    ///
    /// The raw field value is not included in `Display` output to avoid GDPR-sensitive
    /// data (process codes) leaking into operator logs.  Access via `raw_value` field
    /// for diagnostic purposes.  This is distinct from
    /// [`Error::InvalidPruefidentifikatorRange`] so callers can cleanly distinguish
    /// "wrong number" from "not a number".
    #[error("Pruefidentifikator field is not a decimal integer (non-numeric content in BGM)")]
    InvalidPruefidentifikatorFormat {
        /// The raw non-numeric field value.  Not emitted in `Display`; available for debugging.
        raw_value: String,
    },

    /// The release / association-code field in UNH was absent or empty.
    #[error("release code is missing in UNH segment")]
    MissingRelease,

    /// A release code supplied to [`Release::try_new`] failed validation.
    ///
    /// [`Release::try_new`]: crate::Release::try_new
    #[error("invalid release code: {0}")]
    InvalidRelease(&'static str),

    /// No profile was registered for the given message type + release combination.
    ///
    /// Name the Formatversion in `profiles/sources.json` and import it with
    /// `cargo xtask import-profiles`.
    #[error("no profile found for message type {message_type:?} release {release}")]
    ProfileNotFound {
        /// The EDIFACT message type.
        message_type: crate::MessageType,
        /// The release / association code.
        release: crate::Release,
    },

    /// The requested profile exists but has not yet become normatively valid on `date`.
    ///
    /// The profile's `valid_from` is in the future relative to the processing date.
    /// Either use a later processing date, or accept the outgoing profile for now.
    #[error(
        "profile for {message_type:?} release {release} is not yet active on {date} (valid from {valid_from})"
    )]
    ProfileNotYetActive {
        /// The EDIFACT message type.
        message_type: crate::MessageType,
        /// The release / association code.
        release: crate::Release,
        /// The date the profile first becomes valid.
        valid_from: time::Date,
        /// The date that was requested.
        date: time::Date,
    },

    /// Validation completed but the report contains at least one error-level issue.
    ///
    /// Inspect [`EdiEnergyReport`] for the full list of findings.
    #[error("validation failed with {count} error(s): {report}")]
    Validation {
        /// Number of error-level issues.
        count: usize,
        /// The full validation report.
        report: EdiEnergyReport,
    },

    /// A wrapped I/O error (e.g. from reader-based parsing).
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// A profile configuration error (bad or missing profile data).
    ///
    /// This error is returned when a programmatically constructed profile omits
    /// mandatory fields. A profile imported by `cargo xtask import-profiles`
    /// never produces this error.
    #[error("profile configuration error: {0}")]
    Profile(#[from] ProfileError),

    /// The UNZ message count does not match the number of UNH…UNT pairs found
    /// in the interchange.
    ///
    /// Indicates a truncated, padded, or tampered interchange.
    #[error(
        "interchange UNZ count mismatch: UNZ declared {declared} message(s) but {actual} were found"
    )]
    InterchangeCountMismatch {
        /// Count declared in the UNZ segment.
        declared: usize,
        /// Count of UNH…UNT message windows actually found.
        actual: usize,
    },

    /// The interchange control reference in UNZ does not match the one in UNB.
    ///
    /// Per EDIFACT syntax, UNZ DE 0036 must equal UNB DE 0020.
    #[error("interchange control reference mismatch: UNB has {unb_ref:?} but UNZ has {unz_ref:?}")]
    InterchangeRefMismatch {
        /// Control reference from the UNB segment.
        unb_ref: String,
        /// Control reference from the UNZ segment.
        unz_ref: String,
    },

    /// A message's `NAD` party MP-ID disagrees with the interchange envelope.
    ///
    /// BDEW Allgemeine Festlegungen V6.1d §2.13:
    ///
    /// > "Die im UNB- und NAD-Segment für den Absender / Empfänger verwendeten
    /// > MP-ID sind identisch."
    ///
    /// The rule holds for every EDI@Energy message. Accepting a mismatch lets an
    /// authenticated partner attribute a message to a different market
    /// participant at the business layer, because downstream logic (consent
    /// gates, partner lookup, role resolution) reads `NAD`, while the transport
    /// authenticated the envelope.
    #[error(
        "interchange party mismatch: UNB {qualifier} is {unb_id:?} but NAD+{nad_qualifier} \
         is {nad_id:?} (message {message_index}) — BDEW Allgemeine Festlegungen §2.13 \
         requires them to be identical"
    )]
    InterchangePartyMismatch {
        /// `"DE0004"` (sender) or `"DE0010"` (receiver).
        qualifier: &'static str,
        /// `"MS"` (sender) or `"MR"` (receiver).
        nad_qualifier: &'static str,
        /// MP-ID from the interchange envelope.
        unb_id: String,
        /// MP-ID from the message's NAD segment.
        nad_id: String,
        /// Zero-based index of the offending message inside the interchange.
        message_index: usize,
    },

    /// The interchange exceeds the configured `max_messages_per_interchange` limit.
    ///
    /// Increase [`crate::ParseConfig::max_messages_per_interchange`] or process a
    /// smaller interchange.
    #[error("interchange exceeds the maximum allowed message count of {limit}")]
    TooManyMessages {
        /// The configured limit.
        limit: usize,
    },

    /// A single EDIFACT message (UNH…UNT) exceeds the configured
    /// `max_segments_per_message` limit.
    ///
    /// Increase [`crate::ParseConfig::max_segments_per_message`] or reject the
    /// oversized message. This limit is a `DoS` defence for the inbound parser path.
    #[error("message exceeds the maximum allowed segment count of {limit} (actual: {actual})")]
    TooManySegmentsInMessage {
        /// The configured per-message limit.
        limit: usize,
        /// The number of segments in the offending message.
        actual: usize,
    },
}

#[cfg(feature = "diagnostics")]
impl miette::Diagnostic for Error {
    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
        let code = match self {
            Error::Parse(_) => "edi-energy::parse",
            Error::Serialize(_) => "edi-energy::serialize",
            Error::FeatureNotEnabled { .. } => "edi-energy::feature-not-enabled",
            Error::UnknownMessageType { .. } => "edi-energy::unknown-message-type",
            Error::MissingSegment(_) => "edi-energy::missing-segment",
            Error::MalformedSegment(_) => "edi-energy::malformed-segment",
            Error::MissingPruefidentifikator => "edi-energy::missing-pruefidentifikator",
            Error::InvalidPruefidentifikatorRange(_)
            | Error::InvalidPruefidentifikatorFormat { .. } => {
                "edi-energy::invalid-pruefidentifikator"
            }
            Error::MissingRelease => "edi-energy::missing-release",
            Error::InvalidRelease(_) => "edi-energy::invalid-release",
            Error::ProfileNotFound { .. } => "edi-energy::profile-not-found",
            Error::ProfileNotYetActive { .. } => "edi-energy::profile-not-yet-active",
            Error::Validation { .. } => "edi-energy::validation",
            Error::Io(_) => "edi-energy::io",
            Error::Profile(_) => "edi-energy::profile-config",
            Error::InterchangeCountMismatch { .. } => "edi-energy::interchange-count-mismatch",
            Error::InterchangeRefMismatch { .. } => "edi-energy::interchange-ref-mismatch",
            Error::InterchangePartyMismatch { .. } => "edi-energy::interchange-party-mismatch",
            Error::TooManyMessages { .. } => "edi-energy::too-many-messages",
            Error::TooManySegmentsInMessage { .. } => "edi-energy::too-many-segments-in-message",
        };
        Some(Box::new(code))
    }

    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
        match self {
            Error::FeatureNotEnabled {
                message_type,
                feature,
            } => Some(Box::new(format!(
                "add `{feature}` to the `[features]` section of your Cargo.toml to parse {message_type} messages"
            ))),
            Error::ProfileNotFound {
                message_type,
                release,
            } => Some(Box::new(format!(
                "name {message_type} {release} in profiles/sources.json and run \
                 `cargo xtask import-profiles`"
            ))),
            Error::ProfileNotYetActive {
                message_type,
                release,
                valid_from,
                date,
            } => Some(Box::new(format!(
                "{message_type} {release} is valid from {valid_from}; use a processing date ≥ {valid_from} (requested: {date})"
            ))),
            Error::UnknownMessageType { raw_code } => Some(Box::new(format!(
                "`{raw_code}` is not a recognised EDI@Energy message type"
            ))),
            _ => None,
        }
    }

    fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> {
        match self {
            Error::Parse(e) => Some(e as &dyn miette::Diagnostic),
            _ => None,
        }
    }
}