use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct FormatVersion(Box<str>);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatVersionError {
pub input: String,
pub reason: &'static str,
}
impl fmt::Display for FormatVersionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid FormatVersion {:?}: {}; expected pattern FV<YYYY>-<MM>-<DD>",
self.input, self.reason
)
}
}
impl std::error::Error for FormatVersionError {}
impl FormatVersion {
#[must_use]
pub fn new(v: impl Into<Box<str>>) -> Self {
Self(v.into())
}
pub fn parse(s: &str) -> Result<Self, FormatVersionError> {
let err = |reason| FormatVersionError {
input: s.to_owned(),
reason,
};
if s.len() > 12 {
return Err(err(
"input too long; expected exactly 12 characters (FV<YYYY>-<MM>-<DD>)",
));
}
if s.contains('\0') {
return Err(err("input contains NUL bytes"));
}
let rest = s
.strip_prefix("FV")
.ok_or_else(|| err("must start with 'FV'"))?;
if rest.len() != 10 {
return Err(err("date part must be exactly 10 characters (YYYY-MM-DD)"));
}
let parts: Vec<&str> = rest.splitn(3, '-').collect();
if parts.len() != 3 {
return Err(err("date part must contain exactly two '-' separators"));
}
if parts[0].len() != 4 {
return Err(err("year must be exactly 4 digits"));
}
if parts[1].len() != 2 {
return Err(err("month must be exactly 2 digits"));
}
if parts[2].len() != 2 {
return Err(err("day must be exactly 2 digits"));
}
let year: i32 = parts[0]
.parse()
.map_err(|_| err("year must be a 4-digit number"))?;
let month: u8 = parts[1]
.parse()
.map_err(|_| err("month must be a 2-digit number"))?;
let day: u8 = parts[2]
.parse()
.map_err(|_| err("day must be a 2-digit number"))?;
if year < 2000 {
return Err(err(
"year must be ≥ 2000 (no BDEW format versions exist before then)",
));
}
let month_enum =
time::Month::try_from(month).map_err(|_| err("month must be in range 01–12"))?;
time::Date::from_calendar_date(year, month_enum, day)
.map_err(|_| err("date components do not form a valid calendar date"))?;
Ok(Self(s.into()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for FormatVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for FormatVersion {
fn from(s: &str) -> Self {
Self::new(s)
}
}
impl From<String> for FormatVersion {
fn from(s: String) -> Self {
Self::new(s.into_boxed_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct WorkflowId {
pub name: Box<str>,
pub format_version: FormatVersion,
}
impl WorkflowId {
#[must_use]
pub fn new(name: impl Into<Box<str>>, format_version: impl Into<FormatVersion>) -> Self {
Self {
name: name.into(),
format_version: format_version.into(),
}
}
}
impl fmt::Display for WorkflowId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.name, self.format_version)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum WorkflowVersionPolicy {
Pinned,
#[default]
ForwardCompatible,
Explicit(Vec<FormatVersion>),
}
impl WorkflowVersionPolicy {
#[must_use]
pub fn accepts(&self, fv: &FormatVersion, creation_fv: &FormatVersion) -> bool {
match self {
Self::Pinned => fv == creation_fv,
Self::ForwardCompatible => true,
Self::Explicit(list) => list.contains(fv),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_bdew_versions() {
assert!(FormatVersion::parse("FV2024-10-01").is_ok());
assert!(FormatVersion::parse("FV2025-04-01").is_ok());
assert!(FormatVersion::parse("FV2026-10-01").is_ok());
assert!(FormatVersion::parse("FV2000-01-01").is_ok());
}
#[test]
fn parse_accepts_years_beyond_2100() {
assert!(
FormatVersion::parse("FV2101-04-01").is_ok(),
"2101 must now be valid"
);
assert!(
FormatVersion::parse("FV2500-10-01").is_ok(),
"far-future years must be valid"
);
assert!(
FormatVersion::parse("FV9999-12-31").is_ok(),
"max 4-digit year must be valid"
);
}
#[test]
fn parse_rejects_impossible_calendar_dates() {
assert!(
FormatVersion::parse("FV2024-02-30").is_err(),
"Feb 30 is impossible"
);
assert!(
FormatVersion::parse("FV2025-04-31").is_err(),
"Apr 31 is impossible"
);
assert!(
FormatVersion::parse("FV2100-02-29").is_err(),
"2100 is not a leap year"
);
assert!(
FormatVersion::parse("FV2104-02-29").is_ok(),
"2104-02-29 must be valid"
);
}
#[test]
fn parse_missing_fv_prefix() {
let err = FormatVersion::parse("2024-10-01").unwrap_err();
assert!(err.reason.contains("'FV'"), "reason: {}", err.reason);
}
#[test]
fn parse_wrong_prefix_lowercase() {
assert!(FormatVersion::parse("fv2024-10-01").is_err());
}
#[test]
fn parse_invalid_month() {
assert!(FormatVersion::parse("FV2024-13-01").is_err(), "month 13");
assert!(FormatVersion::parse("FV2024-00-01").is_err(), "month 0");
}
#[test]
fn parse_invalid_day() {
assert!(FormatVersion::parse("FV2024-10-00").is_err(), "day 0");
assert!(FormatVersion::parse("FV2024-10-32").is_err(), "day 32");
assert!(
FormatVersion::parse("FV2025-06-06").is_ok(),
"mid-cycle day must be accepted"
);
assert!(
FormatVersion::parse("FV2026-04-01").is_ok(),
"non-October date must be accepted"
);
}
#[test]
fn parse_roundtrip() {
let s = "FV2025-10-01";
let fv = FormatVersion::parse(s).unwrap();
assert_eq!(fv.as_str(), s);
assert_eq!(fv.to_string(), s);
}
#[test]
fn parse_non_numeric_components() {
assert!(FormatVersion::parse("FVaaaa-10-01").is_err());
assert!(FormatVersion::parse("FV2024-bb-01").is_err());
assert!(FormatVersion::parse("FV2024-10-cc").is_err());
}
}