Skip to main content

spec_driven_docs/domain/
version.rs

1//! Canon version: one strict semantic-version triple.
2//!
3//! Holds parsing, ordering, and rendering only. What a version difference
4//! means for an instance — compatible, upgradable, ahead — is the
5//! verifier's and upgrader's judgment, not this type's.
6
7use std::fmt;
8use std::str::FromStr;
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12/// A `MAJOR.MINOR.PATCH` version as released by the canon.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct CanonVersion {
15    /// Breaking position.
16    pub major: u64,
17    /// Feature position (breaking while the major is zero).
18    pub minor: u64,
19    /// Fix position.
20    pub patch: u64,
21}
22
23impl CanonVersion {
24    /// The version this binary was built as.
25    #[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/// Rejection of a string that is not a strict `X.Y.Z` triple.
42#[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}