mako_engine/version.rs
1//! Workflow versioning types.
2//!
3//! BDEW publishes format updates approximately twice per year (1 April and
4//! 1 October). Each format version has an effective date that is used as the
5//! versioning key — **not** semver — because business semantics change on BDEW
6//! release boundaries, not on library release boundaries.
7//!
8//! A [`WorkflowId`] permanently identifies the combination of workflow name and
9//! format version under which a process was started. Events carry this ID so
10//! replay and migration tooling can route to the correct logic.
11
12use std::fmt;
13
14// ── FormatVersion ─────────────────────────────────────────────────────────────
15
16/// A BDEW EDI@Energy format version effective-date identifier.
17///
18/// BDEW uses the convention `FV<YYYY>-<MM>-<DD>`, e.g. `FV2024-10-01` for the
19/// format version that became effective on 1 October 2024.
20///
21/// Use [`FormatVersion::parse`] to construct from user-supplied strings with
22/// pattern validation. Use [`FormatVersion::new`] only for compile-time
23/// constants where the value is already known-valid.
24///
25/// The inner string is stored opaquely so future BDEW versioning conventions can
26/// be accommodated without breaking the engine API.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
28pub struct FormatVersion(Box<str>);
29
30/// Error returned when a string does not match the `FV<YYYY>-<MM>-<DD>` pattern.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct FormatVersionError {
33 /// The string that failed validation.
34 pub input: String,
35 /// Human-readable explanation.
36 pub reason: &'static str,
37}
38
39impl fmt::Display for FormatVersionError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(
42 f,
43 "invalid FormatVersion {:?}: {}; expected pattern FV<YYYY>-<MM>-<DD>",
44 self.input, self.reason
45 )
46 }
47}
48
49impl std::error::Error for FormatVersionError {}
50
51impl FormatVersion {
52 /// **Unchecked constructor for known-valid compile-time literals only.**
53 ///
54 /// Constructs a `FormatVersion` without validating the input against the
55 /// BDEW `FV<YYYY>-<MM>-<DD>` pattern. Passing an invalid string will not
56 /// panic, but it will produce a value that fails later assertions, fails
57 /// round-trip equality with `FormatVersion::parse`, and may cause
58 /// confusing errors when stored or transmitted.
59 ///
60 /// **Use [`parse`] for all runtime and user-supplied strings.** This
61 /// includes strings read from config files, environment variables,
62 /// EDIFACT messages, API request bodies, or any other external source.
63 ///
64 /// Correct usage — compile-time literal only:
65 ///
66 /// ```
67 /// use mako_engine::version::FormatVersion;
68 ///
69 /// // ✓ Known-valid compile-time literal
70 /// let fv = FormatVersion::new("FV2024-10-01");
71 /// assert_eq!(fv.as_str(), "FV2024-10-01");
72 /// ```
73 ///
74 /// Incorrect usage — use `parse` instead:
75 ///
76 /// ```
77 /// use mako_engine::version::FormatVersion;
78 ///
79 /// // ✗ Do NOT pass user-supplied or deserialized strings to `new`
80 /// // let fv = FormatVersion::new(some_config_value);
81 ///
82 /// // ✓ Use parse for anything that is not a compile-time literal
83 /// let fv = FormatVersion::parse("FV2024-10-01").unwrap();
84 /// ```
85 ///
86 /// [`parse`]: FormatVersion::parse
87 #[must_use]
88 pub fn new(v: impl Into<Box<str>>) -> Self {
89 Self(v.into())
90 }
91
92 /// Parse and validate a BDEW `FV<YYYY>-<MM>-<DD>` format version string.
93 ///
94 /// Accepts exactly the BDEW naming convention. Rejects:
95 /// - Missing `FV` prefix (`"2024-10-01"`)
96 /// - Malformed date components (`"FV2024-13-01"` — month 13)
97 /// - Non-numeric year/month/day
98 /// - Any other format
99 ///
100 /// The year must be ≥ 2000 (no BDEW EDI\@Energy format versions exist before
101 /// then). There is no upper bound: the date is validated using the calendar,
102 /// eliminating the former year-2100 ceiling.
103 ///
104 /// # Errors
105 ///
106 /// Returns [`FormatVersionError`] with the rejected input and a reason
107 /// string when the input does not match the expected pattern.
108 ///
109 /// # Example
110 ///
111 /// ```
112 /// use mako_engine::version::FormatVersion;
113 ///
114 /// assert!(FormatVersion::parse("FV2024-10-01").is_ok());
115 /// assert!(FormatVersion::parse("FV2025-04-01").is_ok());
116 /// // No upper year bound:
117 /// assert!(FormatVersion::parse("FV2101-04-01").is_ok());
118 /// assert!(FormatVersion::parse("FV9999-12-31").is_ok());
119 ///
120 /// assert!(FormatVersion::parse("2024-10-01").is_err()); // missing FV prefix
121 /// assert!(FormatVersion::parse("FV2024-13-01").is_err()); // invalid month
122 /// assert!(FormatVersion::parse("FV2024-00-01").is_err()); // zero month
123 /// assert!(FormatVersion::parse("FV2024-10-00").is_err()); // zero day
124 /// assert!(FormatVersion::parse("FV2024-10-32").is_err()); // day > 31
125 /// assert!(FormatVersion::parse("v2024").is_err()); // wrong prefix
126 /// ```
127 pub fn parse(s: &str) -> Result<Self, FormatVersionError> {
128 let err = |reason| FormatVersionError {
129 input: s.to_owned(),
130 reason,
131 };
132
133 // Reject oversized inputs before any allocation.
134 // "FV" + "YYYY-MM-DD" = 12 characters exactly.
135 if s.len() > 12 {
136 return Err(err(
137 "input too long; expected exactly 12 characters (FV<YYYY>-<MM>-<DD>)",
138 ));
139 }
140
141 // Reject NUL bytes: they pass length checks but produce malformed
142 // JSON when serialized.
143 if s.contains('\0') {
144 return Err(err("input contains NUL bytes"));
145 }
146
147 // Must start with "FV"
148 let rest = s
149 .strip_prefix("FV")
150 .ok_or_else(|| err("must start with 'FV'"))?;
151
152 // Must be exactly "YYYY-MM-DD" (10 chars)
153 if rest.len() != 10 {
154 return Err(err("date part must be exactly 10 characters (YYYY-MM-DD)"));
155 }
156
157 let parts: Vec<&str> = rest.splitn(3, '-').collect();
158 if parts.len() != 3 {
159 return Err(err("date part must contain exactly two '-' separators"));
160 }
161
162 if parts[0].len() != 4 {
163 return Err(err("year must be exactly 4 digits"));
164 }
165 if parts[1].len() != 2 {
166 return Err(err("month must be exactly 2 digits"));
167 }
168 if parts[2].len() != 2 {
169 return Err(err("day must be exactly 2 digits"));
170 }
171
172 let year: i32 = parts[0]
173 .parse()
174 .map_err(|_| err("year must be a 4-digit number"))?;
175 let month: u8 = parts[1]
176 .parse()
177 .map_err(|_| err("month must be a 2-digit number"))?;
178 let day: u8 = parts[2]
179 .parse()
180 .map_err(|_| err("day must be a 2-digit number"))?;
181
182 if year < 2000 {
183 return Err(err(
184 "year must be ≥ 2000 (no BDEW format versions exist before then)",
185 ));
186 }
187
188 // Validate using the calendar — this checks month range, day-in-month,
189 // leap-year validity, and future centuries without any year ceiling.
190 let month_enum =
191 time::Month::try_from(month).map_err(|_| err("month must be in range 01–12"))?;
192 time::Date::from_calendar_date(year, month_enum, day)
193 .map_err(|_| err("date components do not form a valid calendar date"))?;
194
195 // BDEW releases on 01-04 or 01-10 in the normal cycle, but interim
196 // corrections (e.g. APERAK MIG 2.1i effective 2025-06-06, REMADV MIG
197 // 2.9e effective 2026-04-01) use non-01 days. We accept any valid date.
198
199 Ok(Self(s.into()))
200 }
201
202 /// The raw format version string.
203 #[must_use]
204 pub fn as_str(&self) -> &str {
205 &self.0
206 }
207}
208
209impl fmt::Display for FormatVersion {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 f.write_str(&self.0)
212 }
213}
214
215impl PartialOrd for FormatVersion {
216 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
217 Some(self.cmp(other))
218 }
219}
220
221impl Ord for FormatVersion {
222 /// Compare two format versions chronologically.
223 ///
224 /// The `FV<YYYY>-<MM>-<DD>` prefix is fixed-width and zero-padded, so
225 /// lexicographic byte comparison is identical to chronological ordering.
226 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
227 self.0.cmp(&other.0)
228 }
229}
230
231impl From<&str> for FormatVersion {
232 fn from(s: &str) -> Self {
233 Self::new(s)
234 }
235}
236
237impl From<String> for FormatVersion {
238 fn from(s: String) -> Self {
239 Self::new(s.into_boxed_str())
240 }
241}
242
243// ── WorkflowId ────────────────────────────────────────────────────────────────
244
245/// Uniquely identifies a versioned workflow definition.
246///
247/// A process started under `gpke-supplier-change / FV2024-10-01` continues to
248/// execute under that version until it completes, even after `FV2025-10-01` is
249/// deployed. Both versions coexist in the running engine.
250///
251/// # Example
252///
253/// ```
254/// use mako_engine::version::{FormatVersion, WorkflowId};
255///
256/// let id = WorkflowId::new("gpke-supplier-change", "FV2024-10-01");
257/// assert_eq!(id.name.as_ref(), "gpke-supplier-change");
258/// ```
259#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
260pub struct WorkflowId {
261 /// Workflow name, e.g. `"gpke-supplier-change"`.
262 pub name: Box<str>,
263 /// BDEW format version under which this workflow was initiated.
264 pub format_version: FormatVersion,
265}
266
267impl WorkflowId {
268 /// Construct a workflow identity.
269 #[must_use]
270 pub fn new(name: impl Into<Box<str>>, format_version: impl Into<FormatVersion>) -> Self {
271 Self {
272 name: name.into(),
273 format_version: format_version.into(),
274 }
275 }
276}
277
278impl fmt::Display for WorkflowId {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 write!(f, "{}@{}", self.name, self.format_version)
281 }
282}
283
284// ── WorkflowVersionPolicy ─────────────────────────────────────────────────────
285
286/// Declares which BDEW format versions a [`Workflow`] implementation can
287/// accept over the lifetime of in-flight processes.
288///
289/// BDEW releases two annual format updates. Processes that span a release
290/// boundary (e.g. a MABIS billing process that starts in October and settles
291/// in January) must accept inbound messages from both the old and the new
292/// format version. `WorkflowVersionPolicy` makes this acceptance declaration
293/// explicit and compiler-checked.
294///
295/// The engine can use this policy to validate that an incoming message's
296/// format version is acceptable *before* constructing the command, surfacing
297/// the gap at dispatch time rather than during a runtime deserialization error.
298///
299/// # Default
300///
301/// The default implementation of [`Workflow::version_policy()`] returns
302/// [`WorkflowVersionPolicy::ForwardCompatible`], which accepts messages in any
303/// format version ≥ the creation FV. This is the correct default for the
304/// majority of BDEW MaKo processes, which routinely span annual release
305/// boundaries. Override to [`Pinned`] only for strictly short-lived workflows
306/// that are guaranteed to complete within a single BDEW release cycle.
307///
308/// [`Workflow::version_policy()`]: crate::workflow::Workflow::version_policy
309/// [`Pinned`]: WorkflowVersionPolicy::Pinned
310///
311/// # Example
312///
313/// ```rust,ignore
314/// use mako_engine::version::{FormatVersion, WorkflowVersionPolicy};
315///
316/// // A GPKE process that lives at most 24 hours — pinned to creation FV:
317/// fn version_policy() -> WorkflowVersionPolicy {
318/// WorkflowVersionPolicy::Pinned
319/// }
320///
321/// // A MABIS billing process that spans the annual release boundary:
322/// fn version_policy() -> WorkflowVersionPolicy {
323/// WorkflowVersionPolicy::Explicit(vec![
324/// FormatVersion::new("FV2025-10-01"),
325/// FormatVersion::new("FV2026-10-01"),
326/// ])
327/// }
328///
329/// // An open-ended process that accepts all FVs >= creation:
330/// fn version_policy() -> WorkflowVersionPolicy {
331/// WorkflowVersionPolicy::ForwardCompatible
332/// }
333/// ```
334///
335/// [`Workflow`]: crate::workflow::Workflow
336#[derive(Debug, Clone, PartialEq, Eq, Default)]
337#[non_exhaustive]
338pub enum WorkflowVersionPolicy {
339 /// Accept only the format version recorded at process creation.
340 ///
341 /// Use for strictly short-lived workflows that are guaranteed to complete
342 /// within a single BDEW release cycle (< 6 months). All counterparty
343 /// messages must arrive before the next October-1 or April-1 FV boundary.
344 ///
345 /// This is a **stricter** policy than the default
346 /// [`ForwardCompatible`](WorkflowVersionPolicy::ForwardCompatible). Most
347 /// BDEW market-communication processes span release boundaries; prefer
348 /// `ForwardCompatible` unless you have an explicit reason to pin.
349 Pinned,
350
351 /// Accept any format version greater than or equal to the creation FV.
352 ///
353 /// **This is the default** (via `#[default]`) for all MaKo workflows.
354 ///
355 /// MaKo processes routinely span BDEW annual release boundaries: a
356 /// Lieferbeginn process started on 2025-09-20 must still accept the
357 /// counterparty's APERAK reply sent on 2025-11-10 under the new
358 /// FV2025-10-01 rules. `ForwardCompatible` handles this transparently.
359 ///
360 /// [`Workflow::version_policy()`]: crate::workflow::Workflow::version_policy
361 #[default]
362 ForwardCompatible,
363
364 /// Accept exactly the listed format versions.
365 ///
366 /// Use when the set of acceptable format versions is known at compile time
367 /// (e.g. a billing process that must handle exactly FV2025-10-01 and
368 /// FV2026-10-01).
369 Explicit(Vec<FormatVersion>),
370}
371
372impl WorkflowVersionPolicy {
373 /// Returns `true` if `fv` is acceptable under this policy given
374 /// `creation_fv` (the format version recorded in the process's
375 /// [`WorkflowId`]).
376 ///
377 /// # Behaviour
378 ///
379 /// | Policy | Acceptance |
380 /// |--------|-----------|
381 /// | `Pinned` | `fv == creation_fv` |
382 /// | `ForwardCompatible` | always (caller treats any FV as acceptable) |
383 /// | `Explicit(list)` | `fv` is in `list` |
384 #[must_use]
385 pub fn accepts(&self, fv: &FormatVersion, creation_fv: &FormatVersion) -> bool {
386 match self {
387 Self::Pinned => fv == creation_fv,
388 Self::ForwardCompatible => true,
389 Self::Explicit(list) => list.contains(fv),
390 }
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn parse_valid_bdew_versions() {
400 assert!(FormatVersion::parse("FV2024-10-01").is_ok());
401 assert!(FormatVersion::parse("FV2025-04-01").is_ok());
402 assert!(FormatVersion::parse("FV2026-10-01").is_ok());
403 assert!(FormatVersion::parse("FV2000-01-01").is_ok());
404 }
405
406 /// the former year-2100 ceiling must be gone.
407 #[test]
408 fn parse_accepts_years_beyond_2100() {
409 assert!(
410 FormatVersion::parse("FV2101-04-01").is_ok(),
411 "2101 must now be valid"
412 );
413 assert!(
414 FormatVersion::parse("FV2500-10-01").is_ok(),
415 "far-future years must be valid"
416 );
417 assert!(
418 FormatVersion::parse("FV9999-12-31").is_ok(),
419 "max 4-digit year must be valid"
420 );
421 }
422
423 /// Calendar validation catches impossible dates (e.g. Feb 30).
424 #[test]
425 fn parse_rejects_impossible_calendar_dates() {
426 assert!(
427 FormatVersion::parse("FV2024-02-30").is_err(),
428 "Feb 30 is impossible"
429 );
430 assert!(
431 FormatVersion::parse("FV2025-04-31").is_err(),
432 "Apr 31 is impossible"
433 );
434 assert!(
435 FormatVersion::parse("FV2100-02-29").is_err(),
436 "2100 is not a leap year"
437 );
438 // 2104 IS a leap year, so Feb 29 is valid.
439 assert!(
440 FormatVersion::parse("FV2104-02-29").is_ok(),
441 "2104-02-29 must be valid"
442 );
443 }
444
445 #[test]
446 fn parse_missing_fv_prefix() {
447 let err = FormatVersion::parse("2024-10-01").unwrap_err();
448 assert!(err.reason.contains("'FV'"), "reason: {}", err.reason);
449 }
450
451 #[test]
452 fn parse_wrong_prefix_lowercase() {
453 assert!(FormatVersion::parse("fv2024-10-01").is_err());
454 }
455
456 #[test]
457 fn parse_invalid_month() {
458 assert!(FormatVersion::parse("FV2024-13-01").is_err(), "month 13");
459 assert!(FormatVersion::parse("FV2024-00-01").is_err(), "month 0");
460 }
461
462 #[test]
463 fn parse_invalid_day() {
464 assert!(FormatVersion::parse("FV2024-10-00").is_err(), "day 0");
465 assert!(FormatVersion::parse("FV2024-10-32").is_err(), "day 32");
466 // non-01 days are now VALID (APERAK MIG 2.1i: FV2025-06-06)
467 assert!(
468 FormatVersion::parse("FV2025-06-06").is_ok(),
469 "mid-cycle day must be accepted"
470 );
471 assert!(
472 FormatVersion::parse("FV2026-04-01").is_ok(),
473 "non-October date must be accepted"
474 );
475 }
476
477 #[test]
478 fn parse_roundtrip() {
479 let s = "FV2025-10-01";
480 let fv = FormatVersion::parse(s).unwrap();
481 assert_eq!(fv.as_str(), s);
482 assert_eq!(fv.to_string(), s);
483 }
484
485 #[test]
486 fn parse_non_numeric_components() {
487 assert!(FormatVersion::parse("FVaaaa-10-01").is_err());
488 assert!(FormatVersion::parse("FV2024-bb-01").is_err());
489 assert!(FormatVersion::parse("FV2024-10-cc").is_err());
490 }
491}