edi-energy 0.15.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.
#[allow(dead_code)]
pub(crate) fn sanitize_code(s: &str) -> String {
    const MAX_LEN: usize = 16;
    let truncated = if s.len() > MAX_LEN { &s[..MAX_LEN] } else { s };
    truncated
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '.' {
                c
            } else {
                '?'
            }
        })
        .collect()
}

// ── 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 profiles generated by `cargo xtask codegen`,
    /// 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.
    ///
    /// Run `cargo xtask codegen` to generate profile data from `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 is compiled out — enable the feature flag to include it.
    ///
    /// This error is returned when the release/message-type combination is a known
    /// **archived** profile that exists in the `edi-energy` codebase but is excluded
    /// from the current build by a feature gate (e.g. `contrl-archive`, `mscons-archive`,
    /// `insrpt-archive`, or the catch-all `archive` feature).
    ///
    /// ## How to fix
    ///
    /// Add the required feature to your `Cargo.toml`:
    ///
    /// ```toml
    /// [dependencies]
    /// edi-energy = { version = "...", features = ["contrl-archive"] }
    /// ```
    ///
    /// ## Background
    ///
    /// Archived profiles are kept in the source tree for retroactive validation
    /// (audit / dispute resolution) but excluded from default builds to keep
    /// binary size and compile times small.  For the `contrl` message type, the
    /// archived `FV2025-10-01` profile covers interchanges from October 2025 –
    /// December 2025 (before `contrl_fv20260101` became active on 2026-01-01).
    #[error(
        "profile for {message_type:?} release {release} is archived \
         (enable feature \"{feature_flag}\" to include it)"
    )]
    ProfileArchived {
        /// The EDIFACT message type.
        message_type: crate::MessageType,
        /// The release / association code.
        release: crate::Release,
        /// The Cargo feature flag needed to include this profile.
        feature_flag: &'static str,
    },

    /// 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,
    },

    /// The requested profile has expired on `date`.
    ///
    /// The profile's `valid_until` date is before the processing date.
    /// Use the successor profile that is valid on this date instead.
    #[error(
        "profile for {message_type:?} release {release} expired on {valid_until} (requested date: {date})"
    )]
    ProfileExpired {
        /// The EDIFACT message type.
        message_type: crate::MessageType,
        /// The release / association code.
        release: crate::Release,
        /// The last date the profile was valid.
        valid_until: 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.  Profiles generated by `cargo xtask codegen` never
    /// produce 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,
    },

    /// 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::ProfileArchived { .. } => "edi-energy::profile-archived",
            Error::ProfileNotYetActive { .. } => "edi-energy::profile-not-yet-active",
            Error::ProfileExpired { .. } => "edi-energy::profile-expired",
            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::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!(
                "run `cargo xtask codegen` to generate profile data for {message_type} {release}"
            ))),
            Error::ProfileArchived {
                message_type,
                release,
                feature_flag,
            } => Some(Box::new(format!(
                "add `{feature_flag}` to your Cargo.toml features to enable the archived {message_type} {release} profile"
            ))),
            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::ProfileExpired {
                message_type,
                release,
                valid_until,
                date,
            } => Some(Box::new(format!(
                "{message_type} {release} expired on {valid_until}; use a successor profile valid on {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,
        }
    }
}