fulltime_plugin_api/version.rs
1//! Independent `major.minor` version identifiers for the canonical schema and the
2//! data-provider interface, plus the consumer-compatibility check the host runs before
3//! loading a plugin.
4//!
5//! See `openspec/changes/define-league-data-contract/design.md` ("Schema version and
6//! interface version are tracked independently") and both specs' "Versioning" scenarios.
7
8use core::fmt;
9use core::str::FromStr;
10
11/// A `major.minor` version identifier for either the canonical schema or the
12/// data-provider interface.
13///
14/// # Examples
15///
16/// ```
17/// use fulltime_plugin_api::Version;
18///
19/// let host = Version::new(1, 3);
20/// let plugin = Version::new(1, 2);
21/// assert!(host.accepts(plugin));
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct Version {
25 /// Major version. A mismatch is always incompatible.
26 pub major: u16,
27 /// Minor version. A host may run a plugin targeting an equal or lower minor version.
28 pub minor: u16,
29}
30
31impl Version {
32 /// Creates a new version identifier.
33 #[must_use]
34 pub const fn new(major: u16, minor: u16) -> Self {
35 Self { major, minor }
36 }
37
38 /// Returns whether `self`, acting as the host's supported version, accepts a plugin
39 /// declaring `target` as the version it was built against.
40 ///
41 /// Compatible when the major versions match and the host's minor version is equal to
42 /// or greater than the plugin's, since a higher host minor version is a superset of
43 /// the fields or functions the plugin was built against.
44 ///
45 /// # Examples
46 ///
47 /// ```
48 /// use fulltime_plugin_api::Version;
49 ///
50 /// assert!(Version::new(1, 3).accepts(Version::new(1, 2)));
51 /// assert!(!Version::new(1, 1).accepts(Version::new(1, 2)));
52 /// assert!(!Version::new(2, 0).accepts(Version::new(1, 9)));
53 /// ```
54 #[must_use]
55 pub const fn accepts(self, target: Self) -> bool {
56 self.major == target.major && self.minor >= target.minor
57 }
58}
59
60impl fmt::Display for Version {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 write!(f, "{}.{}", self.major, self.minor)
63 }
64}
65
66/// Error returned when parsing a [`Version`] from a string fails.
67#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
68#[error("invalid version string {0:?}, expected \"major.minor\"")]
69pub struct ParseVersionError(String);
70
71impl FromStr for Version {
72 type Err = ParseVersionError;
73
74 /// Parses a `"major.minor"` version string.
75 ///
76 /// # Errors
77 ///
78 /// Returns [`ParseVersionError`] if the string is not exactly two `u16` components
79 /// separated by a single `.`.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use fulltime_plugin_api::Version;
85 ///
86 /// assert_eq!("1.2".parse(), Ok(Version::new(1, 2)));
87 /// assert!("1".parse::<Version>().is_err());
88 /// ```
89 fn from_str(s: &str) -> Result<Self, Self::Err> {
90 let (major, minor) = s
91 .split_once('.')
92 .ok_or_else(|| ParseVersionError(s.to_owned()))?;
93 let major = major.parse().map_err(|_| ParseVersionError(s.to_owned()))?;
94 let minor = minor.parse().map_err(|_| ParseVersionError(s.to_owned()))?;
95 Ok(Self { major, minor })
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn accepts_same_major_lower_minor() {
105 assert!(Version::new(1, 3).accepts(Version::new(1, 2)));
106 }
107
108 #[test]
109 fn rejects_same_major_higher_minor() {
110 assert!(!Version::new(1, 1).accepts(Version::new(1, 2)));
111 }
112
113 #[test]
114 fn rejects_different_major() {
115 assert!(!Version::new(2, 0).accepts(Version::new(1, 9)));
116 }
117
118 #[test]
119 fn round_trips_through_display_and_parse() {
120 let v = Version::new(3, 7);
121 assert_eq!(v.to_string().parse(), Ok(v));
122 }
123
124 #[test]
125 fn rejects_malformed_strings() {
126 assert!("1".parse::<Version>().is_err());
127 assert!("1.2.3".parse::<Version>().is_err());
128 assert!("a.b".parse::<Version>().is_err());
129 }
130}