spec_driven_docs/domain/
version.rs1use std::fmt;
8use std::str::FromStr;
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct CanonVersion {
15 pub major: u64,
17 pub minor: u64,
19 pub patch: u64,
21}
22
23impl CanonVersion {
24 #[must_use]
26 pub fn current() -> Self {
27 Self::from_str(env!("CARGO_PKG_VERSION")).unwrap_or(Self {
28 major: 0,
29 minor: 0,
30 patch: 0,
31 })
32 }
33}
34
35impl fmt::Display for CanonVersion {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43#[error("'{0}' is not a semantic version")]
44pub struct VersionError(String);
45
46impl FromStr for CanonVersion {
47 type Err = VersionError;
48
49 fn from_str(s: &str) -> Result<Self, Self::Err> {
50 let err = || VersionError(s.to_string());
51 let mut parts = s.split('.');
52 let mut next = || -> Result<u64, VersionError> {
53 let part = parts.next().ok_or_else(err)?;
54 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
55 return Err(err());
56 }
57 part.parse().map_err(|_| err())
58 };
59 let version = Self {
60 major: next()?,
61 minor: next()?,
62 patch: next()?,
63 };
64 if parts.next().is_some() {
65 return Err(err());
66 }
67 Ok(version)
68 }
69}
70
71impl Serialize for CanonVersion {
72 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73 serializer.collect_str(self)
74 }
75}
76
77impl<'de> Deserialize<'de> for CanonVersion {
78 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
79 let s = String::deserialize(deserializer)?;
80 s.parse().map_err(serde::de::Error::custom)
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn parses_a_triple() {
90 let v: CanonVersion = "0.2.0".parse().unwrap();
91 assert_eq!((v.major, v.minor, v.patch), (0, 2, 0));
92 assert_eq!(v.to_string(), "0.2.0");
93 }
94
95 #[test]
96 fn rejects_non_triples() {
97 for bad in ["", "1.2", "1.2.3.4", "v1.2.3", "1.2.x", "1..3", "1.2.3-rc1"] {
98 assert!(bad.parse::<CanonVersion>().is_err(), "accepted {bad:?}");
99 }
100 }
101
102 #[test]
103 fn orders_numerically() {
104 let a: CanonVersion = "0.9.0".parse().unwrap();
105 let b: CanonVersion = "0.10.0".parse().unwrap();
106 assert!(a < b);
107 }
108
109 #[test]
110 fn current_matches_cargo_version() {
111 assert_eq!(
112 CanonVersion::current().to_string(),
113 env!("CARGO_PKG_VERSION")
114 );
115 }
116
117 #[test]
118 fn serde_round_trips_as_a_string() {
119 let v: CanonVersion = "1.4.2".parse().unwrap();
120 let json = serde_json::to_string(&v).unwrap();
121 assert_eq!(json, "\"1.4.2\"");
122 assert_eq!(serde_json::from_str::<CanonVersion>(&json).unwrap(), v);
123 }
124}