Skip to main content

edi_energy/
error.rs

1use crate::report::EdiEnergyReport;
2
3// ── Sanitization helper ───────────────────────────────────────────────────────
4
5/// Sanitize an untrusted EDIFACT type-code string for safe inclusion in error
6/// fields, tracing spans, and diagnostic messages.
7///
8/// Valid BDEW/EDIFACT type codes are ≤ 16 ASCII alphanumeric characters plus
9/// `.`.  Characters outside that set are replaced with `?` to neutralize
10/// ANSI escape sequences and other log-injection payloads while keeping the
11/// value informative.
12#[allow(dead_code)]
13pub(crate) fn sanitize_code(s: &str) -> String {
14    const MAX_LEN: usize = 16;
15    let truncated = if s.len() > MAX_LEN { &s[..MAX_LEN] } else { s };
16    truncated
17        .chars()
18        .map(|c| {
19            if c.is_ascii_alphanumeric() || c == '.' {
20                c
21            } else {
22                '?'
23            }
24        })
25        .collect()
26}
27
28// ── ProfileError ──────────────────────────────────────────────────────────────
29
30/// Errors that arise from a malformed or incomplete profile configuration.
31///
32/// These are distinct from message-validation errors ([`Error::Validation`]).
33/// A `ProfileError` signals that the *profile itself* is incorrect — e.g. a
34/// required field is missing from a `profiles/**/*.json` file.  Message-level
35/// errors are represented by [`Error::Validation`].
36#[derive(Debug, thiserror::Error)]
37pub enum ProfileError {
38    /// A mandatory field is absent from the profile data.
39    ///
40    /// This should never occur for profiles generated by `cargo xtask codegen`,
41    /// but it can arise when building profiles programmatically.
42    #[error("profile field `{field}` is mandatory but was not provided")]
43    MissingField {
44        /// The name of the missing field, e.g. `"message_type"` or `"release"`.
45        ///
46        /// `Cow<'static, str>` allows both static literals (zero-cost) and
47        /// dynamically computed field names (owned `String`).
48        field: std::borrow::Cow<'static, str>,
49    },
50
51    /// A field value is present but does not meet the profile's constraints.
52    #[error("profile field `{field}` has invalid value {value:?}: {reason}")]
53    InvalidField {
54        /// The name of the invalid field.
55        field: &'static str,
56        /// The rejected value.
57        value: String,
58        /// Human-readable explanation.
59        reason: String,
60    },
61}
62
63// ── Error ─────────────────────────────────────────────────────────────────────
64
65/// All errors that can be produced by `edi-energy`.
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum Error {
69    /// The underlying EDIFACT parser rejected the input.
70    #[error("EDIFACT parse error: {0}")]
71    Parse(#[from] edifact_rs::EdifactError),
72
73    /// Writing an EDIFACT structure failed (envelope or segment serialization).
74    ///
75    /// Distinct from [`Parse`](Self::Parse): the input was accepted, but the
76    /// output could not be rendered — e.g. a value outside the UNOC character
77    /// set or beyond a data element's length bound.
78    #[error("EDIFACT serialization error: {0}")]
79    Serialize(String),
80
81    /// The message type is known but the corresponding Cargo feature is not compiled in.
82    ///
83    /// Enable the `feature` Cargo feature for this crate to parse `message_type` messages.
84    #[error("message type {message_type:?} requires the disabled `{feature}` Cargo feature")]
85    FeatureNotEnabled {
86        /// The EDIFACT message type code, e.g. `"UTILMD"`.
87        message_type: String,
88        /// The Cargo feature name that must be enabled, e.g. `"utilmd"`.
89        feature: String,
90    },
91
92    /// The message type code from UNH is not recognised by this crate at all.
93    ///
94    /// The raw code is not included in `Display` output to avoid GDPR-sensitive
95    /// data leaking into operator logs.  Access the code via the `raw_code` field
96    /// when needed for diagnostic purposes.
97    ///
98    /// The `raw_code` value is sanitized at construction: characters outside
99    /// ASCII alphanumeric and `.` are replaced with `?` so log-injection sequences
100    /// are neutralized before the value enters any tracing span or log record.
101    #[error("unknown EDIFACT message type code (check UNH DE 0065 element 1 component 0)")]
102    UnknownMessageType {
103        /// The sanitized UNH type code.  Not emitted in `Display`; available for debugging.
104        raw_code: String,
105    },
106
107    /// A mandatory EDIFACT segment is absent from the message.
108    #[error("required segment {0} is missing")]
109    MissingSegment(&'static str),
110
111    /// A segment was found but its content is structurally invalid.
112    #[error("malformed segment {0}")]
113    MalformedSegment(&'static str),
114
115    /// The BGM document-identifier field (Pruefidentifikator) was not present.
116    #[error("Pruefidentifikator not found in BGM segment")]
117    MissingPruefidentifikator,
118
119    /// A parsed Pruefidentifikator is outside the valid 5-digit range (10000–99999).
120    ///
121    /// The invalid numeric value is carried as context for diagnostic messages.
122    #[error("invalid Pruefidentifikator {0}: must be a 5-digit code in the range 10000–99999")]
123    InvalidPruefidentifikatorRange(u32),
124
125    /// The Pruefidentifikator field is not a decimal integer at all.
126    ///
127    /// The raw field value is not included in `Display` output to avoid GDPR-sensitive
128    /// data (process codes) leaking into operator logs.  Access via `raw_value` field
129    /// for diagnostic purposes.  This is distinct from
130    /// [`Error::InvalidPruefidentifikatorRange`] so callers can cleanly distinguish
131    /// "wrong number" from "not a number".
132    #[error("Pruefidentifikator field is not a decimal integer (non-numeric content in BGM)")]
133    InvalidPruefidentifikatorFormat {
134        /// The raw non-numeric field value.  Not emitted in `Display`; available for debugging.
135        raw_value: String,
136    },
137
138    /// The release / association-code field in UNH was absent or empty.
139    #[error("release code is missing in UNH segment")]
140    MissingRelease,
141
142    /// A release code supplied to [`Release::try_new`] failed validation.
143    ///
144    /// [`Release::try_new`]: crate::Release::try_new
145    #[error("invalid release code: {0}")]
146    InvalidRelease(&'static str),
147
148    /// No profile was registered for the given message type + release combination.
149    ///
150    /// Run `cargo xtask codegen` to generate profile data from `profiles/**`.
151    #[error("no profile found for message type {message_type:?} release {release}")]
152    ProfileNotFound {
153        /// The EDIFACT message type.
154        message_type: crate::MessageType,
155        /// The release / association code.
156        release: crate::Release,
157    },
158
159    /// The requested profile is compiled out — enable the feature flag to include it.
160    ///
161    /// This error is returned when the release/message-type combination is a known
162    /// **archived** profile that exists in the `edi-energy` codebase but is excluded
163    /// from the current build by a feature gate (e.g. `contrl-archive`, `mscons-archive`,
164    /// `insrpt-archive`, or the catch-all `archive` feature).
165    ///
166    /// ## How to fix
167    ///
168    /// Add the required feature to your `Cargo.toml`:
169    ///
170    /// ```toml
171    /// [dependencies]
172    /// edi-energy = { version = "...", features = ["contrl-archive"] }
173    /// ```
174    ///
175    /// ## Background
176    ///
177    /// Archived profiles are kept in the source tree for retroactive validation
178    /// (audit / dispute resolution) but excluded from default builds to keep
179    /// binary size and compile times small.  For the `contrl` message type, the
180    /// archived `FV2025-10-01` profile covers interchanges from October 2025 –
181    /// December 2025 (before `contrl_fv20260101` became active on 2026-01-01).
182    #[error(
183        "profile for {message_type:?} release {release} is archived \
184         (enable feature \"{feature_flag}\" to include it)"
185    )]
186    ProfileArchived {
187        /// The EDIFACT message type.
188        message_type: crate::MessageType,
189        /// The release / association code.
190        release: crate::Release,
191        /// The Cargo feature flag needed to include this profile.
192        feature_flag: &'static str,
193    },
194
195    /// The requested profile exists but has not yet become normatively valid on `date`.
196    ///
197    /// The profile's `valid_from` is in the future relative to the processing date.
198    /// Either use a later processing date, or accept the outgoing profile for now.
199    #[error(
200        "profile for {message_type:?} release {release} is not yet active on {date} (valid from {valid_from})"
201    )]
202    ProfileNotYetActive {
203        /// The EDIFACT message type.
204        message_type: crate::MessageType,
205        /// The release / association code.
206        release: crate::Release,
207        /// The date the profile first becomes valid.
208        valid_from: time::Date,
209        /// The date that was requested.
210        date: time::Date,
211    },
212
213    /// The requested profile has expired on `date`.
214    ///
215    /// The profile's `valid_until` date is before the processing date.
216    /// Use the successor profile that is valid on this date instead.
217    #[error(
218        "profile for {message_type:?} release {release} expired on {valid_until} (requested date: {date})"
219    )]
220    ProfileExpired {
221        /// The EDIFACT message type.
222        message_type: crate::MessageType,
223        /// The release / association code.
224        release: crate::Release,
225        /// The last date the profile was valid.
226        valid_until: time::Date,
227        /// The date that was requested.
228        date: time::Date,
229    },
230
231    /// Validation completed but the report contains at least one error-level issue.
232    ///
233    /// Inspect [`EdiEnergyReport`] for the full list of findings.
234    #[error("validation failed with {count} error(s): {report}")]
235    Validation {
236        /// Number of error-level issues.
237        count: usize,
238        /// The full validation report.
239        report: EdiEnergyReport,
240    },
241
242    /// A wrapped I/O error (e.g. from reader-based parsing).
243    #[error("I/O error: {0}")]
244    Io(#[from] std::io::Error),
245
246    /// A profile configuration error (bad or missing profile data).
247    ///
248    /// This error is returned when a programmatically constructed profile omits
249    /// mandatory fields.  Profiles generated by `cargo xtask codegen` never
250    /// produce this error.
251    #[error("profile configuration error: {0}")]
252    Profile(#[from] ProfileError),
253
254    /// The UNZ message count does not match the number of UNH…UNT pairs found
255    /// in the interchange.
256    ///
257    /// Indicates a truncated, padded, or tampered interchange.
258    #[error(
259        "interchange UNZ count mismatch: UNZ declared {declared} message(s) but {actual} were found"
260    )]
261    InterchangeCountMismatch {
262        /// Count declared in the UNZ segment.
263        declared: usize,
264        /// Count of UNH…UNT message windows actually found.
265        actual: usize,
266    },
267
268    /// The interchange control reference in UNZ does not match the one in UNB.
269    ///
270    /// Per EDIFACT syntax, UNZ DE 0036 must equal UNB DE 0020.
271    #[error("interchange control reference mismatch: UNB has {unb_ref:?} but UNZ has {unz_ref:?}")]
272    InterchangeRefMismatch {
273        /// Control reference from the UNB segment.
274        unb_ref: String,
275        /// Control reference from the UNZ segment.
276        unz_ref: String,
277    },
278
279    /// The interchange exceeds the configured `max_messages_per_interchange` limit.
280    ///
281    /// Increase [`crate::ParseConfig::max_messages_per_interchange`] or process a
282    /// smaller interchange.
283    #[error("interchange exceeds the maximum allowed message count of {limit}")]
284    TooManyMessages {
285        /// The configured limit.
286        limit: usize,
287    },
288
289    /// A single EDIFACT message (UNH…UNT) exceeds the configured
290    /// `max_segments_per_message` limit.
291    ///
292    /// Increase [`crate::ParseConfig::max_segments_per_message`] or reject the
293    /// oversized message. This limit is a `DoS` defence for the inbound parser path.
294    #[error("message exceeds the maximum allowed segment count of {limit} (actual: {actual})")]
295    TooManySegmentsInMessage {
296        /// The configured per-message limit.
297        limit: usize,
298        /// The number of segments in the offending message.
299        actual: usize,
300    },
301}
302
303#[cfg(feature = "diagnostics")]
304impl miette::Diagnostic for Error {
305    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
306        let code = match self {
307            Error::Parse(_) => "edi-energy::parse",
308            Error::Serialize(_) => "edi-energy::serialize",
309            Error::FeatureNotEnabled { .. } => "edi-energy::feature-not-enabled",
310            Error::UnknownMessageType { .. } => "edi-energy::unknown-message-type",
311            Error::MissingSegment(_) => "edi-energy::missing-segment",
312            Error::MalformedSegment(_) => "edi-energy::malformed-segment",
313            Error::MissingPruefidentifikator => "edi-energy::missing-pruefidentifikator",
314            Error::InvalidPruefidentifikatorRange(_)
315            | Error::InvalidPruefidentifikatorFormat { .. } => {
316                "edi-energy::invalid-pruefidentifikator"
317            }
318            Error::MissingRelease => "edi-energy::missing-release",
319            Error::InvalidRelease(_) => "edi-energy::invalid-release",
320            Error::ProfileNotFound { .. } => "edi-energy::profile-not-found",
321            Error::ProfileArchived { .. } => "edi-energy::profile-archived",
322            Error::ProfileNotYetActive { .. } => "edi-energy::profile-not-yet-active",
323            Error::ProfileExpired { .. } => "edi-energy::profile-expired",
324            Error::Validation { .. } => "edi-energy::validation",
325            Error::Io(_) => "edi-energy::io",
326            Error::Profile(_) => "edi-energy::profile-config",
327            Error::InterchangeCountMismatch { .. } => "edi-energy::interchange-count-mismatch",
328            Error::InterchangeRefMismatch { .. } => "edi-energy::interchange-ref-mismatch",
329            Error::TooManyMessages { .. } => "edi-energy::too-many-messages",
330            Error::TooManySegmentsInMessage { .. } => "edi-energy::too-many-segments-in-message",
331        };
332        Some(Box::new(code))
333    }
334
335    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
336        match self {
337            Error::FeatureNotEnabled {
338                message_type,
339                feature,
340            } => Some(Box::new(format!(
341                "add `{feature}` to the `[features]` section of your Cargo.toml to parse {message_type} messages"
342            ))),
343            Error::ProfileNotFound {
344                message_type,
345                release,
346            } => Some(Box::new(format!(
347                "run `cargo xtask codegen` to generate profile data for {message_type} {release}"
348            ))),
349            Error::ProfileArchived {
350                message_type,
351                release,
352                feature_flag,
353            } => Some(Box::new(format!(
354                "add `{feature_flag}` to your Cargo.toml features to enable the archived {message_type} {release} profile"
355            ))),
356            Error::ProfileNotYetActive {
357                message_type,
358                release,
359                valid_from,
360                date,
361            } => Some(Box::new(format!(
362                "{message_type} {release} is valid from {valid_from}; use a processing date ≥ {valid_from} (requested: {date})"
363            ))),
364            Error::ProfileExpired {
365                message_type,
366                release,
367                valid_until,
368                date,
369            } => Some(Box::new(format!(
370                "{message_type} {release} expired on {valid_until}; use a successor profile valid on {date}"
371            ))),
372            Error::UnknownMessageType { raw_code } => Some(Box::new(format!(
373                "`{raw_code}` is not a recognised EDI@Energy message type"
374            ))),
375            _ => None,
376        }
377    }
378
379    fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> {
380        match self {
381            Error::Parse(e) => Some(e as &dyn miette::Diagnostic),
382            _ => None,
383        }
384    }
385}