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}