Skip to main content

protocol_droid/domain/acp/
version.rs

1//! `AcpVersion` — the pinned ACP release, as a value object.
2//!
3//! # Why a major/minor/patch triple
4//!
5//! ACP release tags are `schema-v<major>.<minor>.<patch>` (`schema-v1.25.0`).
6//! The release tag is what the verifier pins; the parsed `AcpVersion`
7//! is what crosses the dispatcher's API boundary. A `u16`-per-field
8//! representation is decimal-clean, the same shape the wire never
9//! carries (the wire does not pass a version) but the same shape
10//! a comparable type like [`crate::domain::protocol_version::ProtocolVersion`]
11//! uses.
12//!
13//! # The `Unknown` variant
14//!
15//! A future ACP release that is published after this build is
16//! compiled shows up as `Unknown { major, minor, patch }`. The
17//! three-tuple form means `Unknown` orders the same as a known
18//! release with the same numbers would, and `serde_json` round-
19//! trips the parsed form losslessly into a debug log. A peer
20//! that announces a release older than the pin (e.g.
21//! `schema-v1.0.0`) deserialises into the same shape the build
22//! knows — the consumer's `FeatureProfile` is what gates
23//! feature-specific code, not the version itself.
24//!
25//! # The ordering invariant
26//!
27//! `Ord` and `PartialOrd` are *hand-written*, comparing by
28//! `(major, minor, patch)` in order. The 1.98 derive fast path
29//! that assumes a derived `PartialOrd` agrees with a hand-written
30//! `Ord` does not apply here; this is the case the rule was
31//! written for.
32
33use core::fmt;
34use core::str::FromStr;
35
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37
38/// A pinned ACP release, or a future release published after this
39/// build was compiled.
40///
41/// Ordered by `major` then `minor` then `patch`, oldest to newest.
42/// The pin is recorded in
43/// [`crates/protocol-droid-schema/spec/MANIFEST.toml`][manifest].
44///
45/// [manifest]: ../../../../../protocol-droid-schema/spec/MANIFEST.toml
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum AcpVersion {
48    /// The pin recorded in the schema `MANIFEST.toml`. The exact
49    /// release is `schema-v1.25.0`; bumping the pin is its own
50    /// task that re-verifies the wire contract.
51    V1_25_0,
52    /// A future ACP release, identified by its `major.minor.patch`.
53    /// Carries the same numbers a known release would; what changes
54    /// between a known release and its `Unknown` peer is whether
55    /// this build's per-version codec has types for it.
56    Unknown {
57        /// The peer-named major version. `u16` because the
58        /// release tag is decimal; a major number that
59        /// overflows `u16` is not a real ACP release.
60        major: u16,
61        /// The peer-named minor version.
62        minor: u16,
63        /// The peer-named patch version.
64        patch: u16,
65    },
66}
67
68const fn parts(version: AcpVersion) -> (u16, u16, u16) {
69    match version {
70        AcpVersion::V1_25_0 => (1, 25, 0),
71        AcpVersion::Unknown {
72            major,
73            minor,
74            patch,
75        } => (major, minor, patch),
76    }
77}
78
79impl PartialOrd for AcpVersion {
80    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
81        Some(self.cmp(other))
82    }
83}
84
85impl Ord for AcpVersion {
86    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
87        parts(*self).cmp(&parts(*other))
88    }
89}
90
91/// The error returned by [`AcpVersion::from_str`] when the input
92/// is not a well-formed `Major.Minor.Patch` triple.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct AcpVersionParseError {
95    input: String,
96    reason: &'static str,
97}
98
99impl fmt::Display for AcpVersionParseError {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(
102            formatter,
103            "ACP version {:?} is invalid: {}",
104            self.input, self.reason
105        )
106    }
107}
108
109impl std::error::Error for AcpVersionParseError {}
110
111impl FromStr for AcpVersion {
112    type Err = AcpVersionParseError;
113
114    fn from_str(input: &str) -> Result<Self, Self::Err> {
115        // The pin is `schema-v1.25.0`; the version-bearing
116        // segment is everything after the `v`. The release
117        // tag's `schema-v` prefix is a namespacing
118        // convention, not part of the version itself.
119        let version_part = input.strip_prefix("schema-v").unwrap_or(input);
120        let mut iter = version_part.split('.');
121        let Some(major) = iter.next() else {
122            return Err(AcpVersionParseError {
123                input: input.to_owned(),
124                reason: "missing the major version",
125            });
126        };
127        let Some(minor) = iter.next() else {
128            return Err(AcpVersionParseError {
129                input: input.to_owned(),
130                reason: "missing the minor version",
131            });
132        };
133        let Some(patch) = iter.next() else {
134            return Err(AcpVersionParseError {
135                input: input.to_owned(),
136                reason: "missing the patch version",
137            });
138        };
139        if iter.next().is_some() {
140            return Err(AcpVersionParseError {
141                input: input.to_owned(),
142                reason: "expected exactly three numeric segments",
143            });
144        }
145        let major = major.parse::<u16>().map_err(|_| AcpVersionParseError {
146            input: input.to_owned(),
147            reason: "the major version is not a u16",
148        })?;
149        let minor = minor.parse::<u16>().map_err(|_| AcpVersionParseError {
150            input: input.to_owned(),
151            reason: "the minor version is not a u16",
152        })?;
153        let patch = patch.parse::<u16>().map_err(|_| AcpVersionParseError {
154            input: input.to_owned(),
155            reason: "the patch version is not a u16",
156        })?;
157        if (1, 25, 0) == (major, minor, patch) {
158            Ok(Self::V1_25_0)
159        } else {
160            Ok(Self::Unknown {
161                major,
162                minor,
163                patch,
164            })
165        }
166    }
167}
168
169impl fmt::Display for AcpVersion {
170    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171        let (major, minor, patch) = parts(*self);
172        write!(formatter, "{major}.{minor}.{patch}")
173    }
174}
175
176impl Serialize for AcpVersion {
177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: Serializer,
180    {
181        serializer.collect_str(self)
182    }
183}
184
185impl<'de> Deserialize<'de> for AcpVersion {
186    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187    where
188        D: Deserializer<'de>,
189    {
190        let raw = String::deserialize(deserializer)?;
191        raw.parse().map_err(serde::de::Error::custom)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use core::cmp::Ordering;
199
200    /// The well-known pin parses to its known
201    /// variant, both with and without the
202    /// `schema-v` prefix.
203    #[test]
204    fn the_pinned_version_parses_with_or_without_the_prefix() -> Result<(), AcpVersionParseError> {
205        let with_prefix = "schema-v1.25.0".parse::<AcpVersion>()?;
206        let without_prefix = "1.25.0".parse::<AcpVersion>()?;
207        assert_eq!(with_prefix, AcpVersion::V1_25_0);
208        assert_eq!(without_prefix, AcpVersion::V1_25_0);
209        Ok(())
210    }
211
212    /// A future release parses into `Unknown` with
213    /// the same `(major, minor, patch)` it carried
214    /// on the wire, and sorts the same as a known
215    /// release with those numbers would.
216    #[test]
217    fn an_unknown_version_orders_like_its_known_equivalent() -> Result<(), AcpVersionParseError> {
218        let unknown: AcpVersion = "1.25.0".parse()?;
219        assert_eq!(unknown, AcpVersion::V1_25_0);
220        assert_eq!(unknown.cmp(&AcpVersion::V1_25_0), Ordering::Equal);
221        let future: AcpVersion = "1.26.0".parse()?;
222        assert!(future > AcpVersion::V1_25_0);
223        let older: AcpVersion = "0.9.0".parse()?;
224        assert!(older < AcpVersion::V1_25_0);
225        Ok(())
226    }
227
228    /// The parse error carries the offending
229    /// input and a human-readable reason. The
230    /// `Display` impl is what an operator
231    /// pasting a release tag into a log line
232    /// reads.
233    #[test]
234    fn a_malformed_input_carries_the_input_and_the_reason() -> Result<(), String> {
235        // `str::split('.')` on the empty string and
236        // on `"1"` both yield a single non-None first
237        // element, so the missing-segment check fires
238        // on the *second* segment, not the first. The
239        // `u16`-parse errors are reachable through
240        // inputs the missing-segment check does not
241        // catch.
242        let cases: [(&str, &str); 5] = [
243            ("", "missing the minor version"),
244            ("1", "missing the minor version"),
245            ("1.2", "missing the patch version"),
246            ("1.2.3.4", "expected exactly three numeric segments"),
247            ("a.b.c", "the major version is not a u16"),
248        ];
249        for (input, expected_reason) in cases {
250            let Err(error) = input.parse::<AcpVersion>() else {
251                return Err(format!("{input:?} should be an error"));
252            };
253            assert_eq!(error.reason, expected_reason, "input: {input:?}");
254            let text = format!("{error}");
255            assert!(
256                text.contains(input),
257                "the text should carry the input: {text}"
258            );
259        }
260        Ok(())
261    }
262
263    /// The round-trip through `Display` and
264    /// `FromStr` is identity for every variant
265    /// the build knows, and for at least one
266    /// `Unknown` peer. A consumer that logs an
267    /// `AcpVersion` and re-parses it gets the
268    /// same value back.
269    #[test]
270    fn display_then_from_str_round_trips_every_known_variant() -> Result<(), AcpVersionParseError> {
271        let known: AcpVersion = AcpVersion::V1_25_0;
272        let round_tripped: AcpVersion = format!("{known}").parse()?;
273        assert_eq!(round_tripped, known);
274        let future: AcpVersion = "1.27.3".parse()?;
275        let round_tripped: AcpVersion = format!("{future}").parse()?;
276        assert_eq!(round_tripped, future);
277        Ok(())
278    }
279}