Skip to main content

compose_lens/validation/
version.rs

1//! Numeric implementation versions and evidence ranges.
2
3use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6
7/// An exact three-component implementation version.
8///
9/// `ComposeLens` deliberately does not infer a current version or interpret pre-release/build
10/// metadata. Callers select the exact released implementation they intend to assess.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct ImplementationVersion {
13    major: u32,
14    minor: u32,
15    patch: u32,
16}
17
18impl ImplementationVersion {
19    /// Creates an exact numeric version.
20    #[must_use]
21    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
22        Self { major, minor, patch }
23    }
24
25    /// Returns the major component.
26    #[must_use]
27    pub const fn major(self) -> u32 {
28        self.major
29    }
30
31    /// Returns the minor component.
32    #[must_use]
33    pub const fn minor(self) -> u32 {
34        self.minor
35    }
36
37    /// Returns the patch component.
38    #[must_use]
39    pub const fn patch(self) -> u32 {
40        self.patch
41    }
42}
43
44impl fmt::Display for ImplementationVersion {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch)
47    }
48}
49
50impl FromStr for ImplementationVersion {
51    type Err = VersionParseError;
52
53    fn from_str(value: &str) -> Result<Self, Self::Err> {
54        let value = value.strip_prefix('v').unwrap_or(value);
55        let mut components = value.split('.');
56        let major = parse_component(components.next())?;
57        let minor = parse_component(components.next())?;
58        let patch = parse_component(components.next())?;
59        if components.next().is_some() {
60            return Err(VersionParseError);
61        }
62        Ok(Self::new(major, minor, patch))
63    }
64}
65
66fn parse_component(component: Option<&str>) -> Result<u32, VersionParseError> {
67    let component = component
68        .filter(|component| !component.is_empty())
69        .ok_or(VersionParseError)?;
70    if !component.bytes().all(|byte| byte.is_ascii_digit()) {
71        return Err(VersionParseError);
72    }
73    component.parse().map_err(|_| VersionParseError)
74}
75
76/// An exact implementation version could not be parsed.
77///
78/// The error does not retain or display the supplied value so version parsing can safely be used
79/// with untrusted command input.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct VersionParseError;
82
83impl fmt::Display for VersionParseError {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        formatter.write_str("expected a three-component numeric implementation version")
86    }
87}
88
89impl Error for VersionParseError {}
90
91/// An inclusive implementation-version range attached to compatibility evidence.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
93pub struct VersionRange {
94    minimum: Option<ImplementationVersion>,
95    maximum: Option<ImplementationVersion>,
96}
97
98impl VersionRange {
99    /// Creates an unbounded range.
100    #[must_use]
101    pub const fn unbounded() -> Self {
102        Self {
103            minimum: None,
104            maximum: None,
105        }
106    }
107
108    /// Creates a range with an inclusive minimum and no maximum.
109    #[must_use]
110    pub const fn from_minimum(minimum: ImplementationVersion) -> Self {
111        Self {
112            minimum: Some(minimum),
113            maximum: None,
114        }
115    }
116
117    /// Creates a range containing one exact version.
118    #[must_use]
119    pub const fn exact(version: ImplementationVersion) -> Self {
120        Self {
121            minimum: Some(version),
122            maximum: Some(version),
123        }
124    }
125
126    /// Creates a checked inclusive range.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`InvalidVersionRange`] when both bounds exist and the minimum is newer than the
131    /// maximum.
132    pub fn new(
133        minimum: Option<ImplementationVersion>,
134        maximum: Option<ImplementationVersion>,
135    ) -> Result<Self, InvalidVersionRange> {
136        if minimum.zip(maximum).is_some_and(|(minimum, maximum)| minimum > maximum) {
137            return Err(InvalidVersionRange);
138        }
139        Ok(Self { minimum, maximum })
140    }
141
142    /// Returns the inclusive minimum.
143    #[must_use]
144    pub const fn minimum(self) -> Option<ImplementationVersion> {
145        self.minimum
146    }
147
148    /// Returns the inclusive maximum.
149    #[must_use]
150    pub const fn maximum(self) -> Option<ImplementationVersion> {
151        self.maximum
152    }
153
154    /// Reports whether an exact version is inside the inclusive range.
155    #[must_use]
156    pub fn contains(self, version: ImplementationVersion) -> bool {
157        self.minimum.is_none_or(|minimum| version >= minimum) && self.maximum.is_none_or(|maximum| version <= maximum)
158    }
159}
160
161/// A version range has its minimum after its maximum.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct InvalidVersionRange;
164
165impl fmt::Display for InvalidVersionRange {
166    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167        formatter.write_str("minimum implementation version is newer than maximum")
168    }
169}
170
171impl Error for InvalidVersionRange {}
172
173#[cfg(test)]
174mod tests {
175    use super::{ImplementationVersion, InvalidVersionRange, VersionParseError, VersionRange};
176
177    #[test]
178    fn exact_versions_accept_documented_spelling_and_expose_every_component() -> Result<(), VersionParseError> {
179        let plain: ImplementationVersion = "2.40.3".parse()?;
180        let prefixed: ImplementationVersion = "v0.1.17".parse()?;
181
182        assert_eq!(plain, ImplementationVersion::new(2, 40, 3));
183        assert_eq!((plain.major(), plain.minor(), plain.patch()), (2, 40, 3));
184        assert_eq!(plain.to_string(), "2.40.3");
185        assert_eq!(prefixed.to_string(), "0.1.17");
186        Ok(())
187    }
188
189    #[test]
190    fn exact_versions_reject_missing_extra_non_numeric_and_overflowing_components() {
191        for spelling in [
192            "",
193            "v",
194            "2",
195            "2.40",
196            "2.40.3.1",
197            ".40.3",
198            "2..3",
199            "2.40.",
200            "2.forty.3",
201            "2.40.3-beta",
202            " 2.40.3",
203            "4294967296.0.0",
204        ] {
205            assert!(
206                spelling.parse::<ImplementationVersion>().is_err(),
207                "unexpectedly accepted {spelling:?}"
208            );
209        }
210
211        assert_eq!(
212            "private-version-value"
213                .parse::<ImplementationVersion>()
214                .err()
215                .map(|error| error.to_string()),
216            Some("expected a three-component numeric implementation version".to_owned())
217        );
218    }
219
220    #[test]
221    fn range_constructors_and_boundaries_are_inclusive() -> Result<(), InvalidVersionRange> {
222        let older = ImplementationVersion::new(5, 4, 0);
223        let newer = ImplementationVersion::new(6, 0, 2);
224
225        let bounded = VersionRange::new(Some(older), Some(newer))?;
226        assert_eq!(bounded.minimum(), Some(older));
227        assert_eq!(bounded.maximum(), Some(newer));
228        assert!(bounded.contains(older));
229        assert!(bounded.contains(newer));
230        assert!(!bounded.contains(ImplementationVersion::new(5, 3, 9)));
231        assert!(!bounded.contains(ImplementationVersion::new(6, 0, 3)));
232
233        let minimum = VersionRange::from_minimum(older);
234        assert!(minimum.contains(older));
235        assert!(minimum.contains(ImplementationVersion::new(u32::MAX, 0, 0)));
236
237        let exact = VersionRange::exact(newer);
238        assert!(exact.contains(newer));
239        assert!(!exact.contains(older));
240
241        let unbounded = VersionRange::unbounded();
242        assert_eq!(unbounded, VersionRange::default());
243        assert!(unbounded.contains(ImplementationVersion::new(0, 0, 0)));
244        assert!(unbounded.contains(ImplementationVersion::new(u32::MAX, u32::MAX, u32::MAX)));
245        Ok(())
246    }
247
248    #[test]
249    fn range_rejects_an_inverted_pair_without_echoing_values() {
250        let result = VersionRange::new(
251            Some(ImplementationVersion::new(6, 0, 2)),
252            Some(ImplementationVersion::new(5, 4, 0)),
253        );
254
255        assert_eq!(result, Err(InvalidVersionRange));
256        assert_eq!(
257            result.err().map(|error| error.to_string()),
258            Some("minimum implementation version is newer than maximum".to_owned())
259        );
260    }
261}