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 From<&str> for FormatVersion {
216 fn from(s: &str) -> Self {
217 Self::new(s)
218 }
219}
220
221impl From<String> for FormatVersion {
222 fn from(s: String) -> Self {
223 Self::new(s.into_boxed_str())
224 }
225}
226
227// ── WorkflowId ────────────────────────────────────────────────────────────────
228
229/// Uniquely identifies a versioned workflow definition.
230///
231/// A process started under `gpke-supplier-change / FV2024-10-01` continues to
232/// execute under that version until it completes, even after `FV2025-10-01` is
233/// deployed. Both versions coexist in the running engine.
234///
235/// # Example
236///
237/// ```
238/// use mako_engine::version::{FormatVersion, WorkflowId};
239///
240/// let id = WorkflowId::new("gpke-supplier-change", "FV2024-10-01");
241/// assert_eq!(id.name.as_ref(), "gpke-supplier-change");
242/// ```
243#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
244pub struct WorkflowId {
245 /// Workflow name, e.g. `"gpke-supplier-change"`.
246 pub name: Box<str>,
247 /// BDEW format version under which this workflow was initiated.
248 pub format_version: FormatVersion,
249}
250
251impl WorkflowId {
252 /// Construct a workflow identity.
253 #[must_use]
254 pub fn new(name: impl Into<Box<str>>, format_version: impl Into<FormatVersion>) -> Self {
255 Self {
256 name: name.into(),
257 format_version: format_version.into(),
258 }
259 }
260}
261
262impl fmt::Display for WorkflowId {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 write!(f, "{}@{}", self.name, self.format_version)
265 }
266}
267
268// ── WorkflowVersionPolicy ─────────────────────────────────────────────────────
269
270/// Declares which BDEW format versions a [`Workflow`] implementation can
271/// accept over the lifetime of in-flight processes.
272///
273/// BDEW releases two annual format updates. Processes that span a release
274/// boundary (e.g. a MABIS billing process that starts in October and settles
275/// in January) must accept inbound messages from both the old and the new
276/// format version. `WorkflowVersionPolicy` makes this acceptance declaration
277/// explicit and compiler-checked.
278///
279/// The engine can use this policy to validate that an incoming message's
280/// format version is acceptable *before* constructing the command, surfacing
281/// the gap at dispatch time rather than during a runtime deserialization error.
282///
283/// # Default
284///
285/// The default implementation of [`Workflow::version_policy()`] returns
286/// [`WorkflowVersionPolicy::ForwardCompatible`], which accepts messages in any
287/// format version ≥ the creation FV. This is the correct default for the
288/// majority of BDEW MaKo processes, which routinely span annual release
289/// boundaries. Override to [`Pinned`] only for strictly short-lived workflows
290/// that are guaranteed to complete within a single BDEW release cycle.
291///
292/// [`Workflow::version_policy()`]: crate::workflow::Workflow::version_policy
293/// [`Pinned`]: WorkflowVersionPolicy::Pinned
294///
295/// # Example
296///
297/// ```rust,ignore
298/// use mako_engine::version::{FormatVersion, WorkflowVersionPolicy};
299///
300/// // A GPKE process that lives at most 24 hours — pinned to creation FV:
301/// fn version_policy() -> WorkflowVersionPolicy {
302/// WorkflowVersionPolicy::Pinned
303/// }
304///
305/// // A MABIS billing process that spans the annual release boundary:
306/// fn version_policy() -> WorkflowVersionPolicy {
307/// WorkflowVersionPolicy::Explicit(vec![
308/// FormatVersion::new("FV2025-10-01"),
309/// FormatVersion::new("FV2026-10-01"),
310/// ])
311/// }
312///
313/// // An open-ended process that accepts all FVs >= creation:
314/// fn version_policy() -> WorkflowVersionPolicy {
315/// WorkflowVersionPolicy::ForwardCompatible
316/// }
317/// ```
318///
319/// [`Workflow`]: crate::workflow::Workflow
320#[derive(Debug, Clone, PartialEq, Eq, Default)]
321#[non_exhaustive]
322pub enum WorkflowVersionPolicy {
323 /// Accept only the format version recorded at process creation.
324 ///
325 /// Use for strictly short-lived workflows that are guaranteed to complete
326 /// within a single BDEW release cycle (< 6 months). All counterparty
327 /// messages must arrive before the next October-1 or April-1 FV boundary.
328 ///
329 /// This is a **stricter** policy than the default
330 /// [`ForwardCompatible`](WorkflowVersionPolicy::ForwardCompatible). Most
331 /// BDEW market-communication processes span release boundaries; prefer
332 /// `ForwardCompatible` unless you have an explicit reason to pin.
333 Pinned,
334
335 /// Accept any format version greater than or equal to the creation FV.
336 ///
337 /// **This is the default** (via `#[default]`) for all MaKo workflows.
338 ///
339 /// MaKo processes routinely span BDEW annual release boundaries: a
340 /// Lieferbeginn process started on 2025-09-20 must still accept the
341 /// counterparty's APERAK reply sent on 2025-11-10 under the new
342 /// FV2025-10-01 rules. `ForwardCompatible` handles this transparently.
343 ///
344 /// [`Workflow::version_policy()`]: crate::workflow::Workflow::version_policy
345 #[default]
346 ForwardCompatible,
347
348 /// Accept exactly the listed format versions.
349 ///
350 /// Use when the set of acceptable format versions is known at compile time
351 /// (e.g. a billing process that must handle exactly FV2025-10-01 and
352 /// FV2026-10-01).
353 Explicit(Vec<FormatVersion>),
354}
355
356impl WorkflowVersionPolicy {
357 /// Returns `true` if `fv` is acceptable under this policy given
358 /// `creation_fv` (the format version recorded in the process's
359 /// [`WorkflowId`]).
360 ///
361 /// # Behaviour
362 ///
363 /// | Policy | Acceptance |
364 /// |--------|-----------|
365 /// | `Pinned` | `fv == creation_fv` |
366 /// | `ForwardCompatible` | always (caller treats any FV as acceptable) |
367 /// | `Explicit(list)` | `fv` is in `list` |
368 #[must_use]
369 pub fn accepts(&self, fv: &FormatVersion, creation_fv: &FormatVersion) -> bool {
370 match self {
371 Self::Pinned => fv == creation_fv,
372 Self::ForwardCompatible => true,
373 Self::Explicit(list) => list.contains(fv),
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn parse_valid_bdew_versions() {
384 assert!(FormatVersion::parse("FV2024-10-01").is_ok());
385 assert!(FormatVersion::parse("FV2025-04-01").is_ok());
386 assert!(FormatVersion::parse("FV2026-10-01").is_ok());
387 assert!(FormatVersion::parse("FV2000-01-01").is_ok());
388 }
389
390 /// the former year-2100 ceiling must be gone.
391 #[test]
392 fn parse_accepts_years_beyond_2100() {
393 assert!(
394 FormatVersion::parse("FV2101-04-01").is_ok(),
395 "2101 must now be valid"
396 );
397 assert!(
398 FormatVersion::parse("FV2500-10-01").is_ok(),
399 "far-future years must be valid"
400 );
401 assert!(
402 FormatVersion::parse("FV9999-12-31").is_ok(),
403 "max 4-digit year must be valid"
404 );
405 }
406
407 /// Calendar validation catches impossible dates (e.g. Feb 30).
408 #[test]
409 fn parse_rejects_impossible_calendar_dates() {
410 assert!(
411 FormatVersion::parse("FV2024-02-30").is_err(),
412 "Feb 30 is impossible"
413 );
414 assert!(
415 FormatVersion::parse("FV2025-04-31").is_err(),
416 "Apr 31 is impossible"
417 );
418 assert!(
419 FormatVersion::parse("FV2100-02-29").is_err(),
420 "2100 is not a leap year"
421 );
422 // 2104 IS a leap year, so Feb 29 is valid.
423 assert!(
424 FormatVersion::parse("FV2104-02-29").is_ok(),
425 "2104-02-29 must be valid"
426 );
427 }
428
429 #[test]
430 fn parse_missing_fv_prefix() {
431 let err = FormatVersion::parse("2024-10-01").unwrap_err();
432 assert!(err.reason.contains("'FV'"), "reason: {}", err.reason);
433 }
434
435 #[test]
436 fn parse_wrong_prefix_lowercase() {
437 assert!(FormatVersion::parse("fv2024-10-01").is_err());
438 }
439
440 #[test]
441 fn parse_invalid_month() {
442 assert!(FormatVersion::parse("FV2024-13-01").is_err(), "month 13");
443 assert!(FormatVersion::parse("FV2024-00-01").is_err(), "month 0");
444 }
445
446 #[test]
447 fn parse_invalid_day() {
448 assert!(FormatVersion::parse("FV2024-10-00").is_err(), "day 0");
449 assert!(FormatVersion::parse("FV2024-10-32").is_err(), "day 32");
450 // non-01 days are now VALID (APERAK MIG 2.1i: FV2025-06-06)
451 assert!(
452 FormatVersion::parse("FV2025-06-06").is_ok(),
453 "mid-cycle day must be accepted"
454 );
455 assert!(
456 FormatVersion::parse("FV2026-04-01").is_ok(),
457 "non-October date must be accepted"
458 );
459 }
460
461 #[test]
462 fn parse_roundtrip() {
463 let s = "FV2025-10-01";
464 let fv = FormatVersion::parse(s).unwrap();
465 assert_eq!(fv.as_str(), s);
466 assert_eq!(fv.to_string(), s);
467 }
468
469 #[test]
470 fn parse_non_numeric_components() {
471 assert!(FormatVersion::parse("FVaaaa-10-01").is_err());
472 assert!(FormatVersion::parse("FV2024-bb-01").is_err());
473 assert!(FormatVersion::parse("FV2024-10-cc").is_err());
474 }
475}