Skip to main content

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}