substrait/parse/proto/
version.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Parsing of [proto::Version].
4
5use crate::{proto, version};
6use hex::FromHex;
7use thiserror::Error;
8
9/// A parsed [proto::Version].
10///
11/// This parses only for compatible versions. See [`version::semver_req`].
12#[derive(Clone, Debug, PartialEq)]
13pub struct Version {
14    /// The semantic version.
15    version: semver::Version,
16    /// The git hash if set as bytes.
17    git_hash: Option<[u8; 20]>,
18    /// The producer string if set.
19    producer: Option<String>,
20}
21
22impl Version {
23    /// Returns the semantic version of this version.
24    ///
25    /// See [proto::Version::major_number], [proto::Version::minor_number] and
26    /// [proto::Version::patch_number].
27    pub fn version(&self) -> &semver::Version {
28        &self.version
29    }
30
31    /// Returns the git hash of this version.
32    ///
33    /// See [proto::Version::git_hash].
34    pub fn git_hash(&self) -> Option<&[u8; 20]> {
35        self.git_hash.as_ref()
36    }
37
38    /// Returns the producer of this version.
39    ///
40    /// See [proto::Version::producer].
41    pub fn producer(&self) -> Option<&str> {
42        self.producer.as_deref()
43    }
44
45    /// Returns [VersionError::Substrait] if this version is incompatible with
46    /// the Substrait [version::version] of this crate.
47    pub(crate) fn compatible(&self) -> Result<(), VersionError> {
48        let version = self.version();
49        let version_req = version::semver_req();
50        version_req
51            .matches(version)
52            .then_some(())
53            .ok_or_else(|| VersionError::Substrait(version.clone(), version_req))
54    }
55}
56
57/// Parse errors for [proto::Version].
58#[derive(Debug, Error, PartialEq)]
59pub enum VersionError {
60    /// Git hash is incorrect.
61    #[error(
62        "git hash must be a lowercase hex ASCII string, 40 characters in length: (git hash: {0})"
63    )]
64    GitHash(String),
65
66    /// Version is missing.
67    #[error("version must be specified")]
68    Missing,
69
70    /// Version is incompatible.
71    #[error("substrait version incompatible (version: `{0}`, supported: `{1}`)")]
72    Substrait(semver::Version, semver::VersionReq),
73}
74
75impl TryFrom<proto::Version> for Version {
76    type Error = VersionError;
77
78    fn try_from(value: proto::Version) -> Result<Self, Self::Error> {
79        let proto::Version {
80            major_number,
81            minor_number,
82            patch_number,
83            git_hash,
84            producer,
85        } = value;
86
87        // All version numbers unset (u32::default()) is an error, because
88        // version is required.
89        if major_number == u32::default()
90            && minor_number == u32::default()
91            && patch_number == u32::default()
92        {
93            return Err(VersionError::Missing);
94        }
95
96        // The git hash, when set, must be a lowercase hex ASCII string, 40
97        // characters in length.
98        if !git_hash.is_empty()
99            && (git_hash.len() != 40
100                || !git_hash.chars().all(|x| matches!(x, '0'..='9' | 'a'..='f')))
101        {
102            return Err(VersionError::GitHash(git_hash));
103        }
104
105        let version = Version {
106            version: semver::Version::new(major_number as _, minor_number as _, patch_number as _),
107            git_hash: (!git_hash.is_empty()).then(|| <[u8; 20]>::from_hex(git_hash).unwrap()),
108            producer: (!producer.is_empty()).then_some(producer),
109        };
110
111        // The version must be compatible with the substrait version of this crate.
112        version.compatible()?;
113
114        Ok(version)
115    }
116}
117
118impl From<Version> for proto::Version {
119    fn from(version: Version) -> Self {
120        let Version {
121            version,
122            git_hash,
123            producer,
124        } = version;
125
126        proto::Version {
127            // Note: we can use `as _` here because this Version is always
128            // constructed from `u32` values.
129            major_number: version.major as _,
130            minor_number: version.minor as _,
131            patch_number: version.patch as _,
132            git_hash: git_hash.map(hex::encode).unwrap_or_default(),
133            producer: producer.unwrap_or_default(),
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn version() -> Result<(), VersionError> {
144        let version = proto::Version::default();
145        assert_eq!(Version::try_from(version), Err(VersionError::Missing));
146
147        let version = version::version();
148        Version::try_from(version)?;
149        Ok(())
150    }
151
152    #[test]
153    fn git_hash() {
154        let base = version::version();
155
156        // Bad length.
157        let git_hash = String::from("short");
158        let version = proto::Version {
159            git_hash: git_hash.clone(),
160            ..base.clone()
161        };
162        assert_eq!(
163            Version::try_from(version),
164            Err(VersionError::GitHash(git_hash))
165        );
166
167        // Not lowercase.
168        let git_hash = String::from("2FD4E1C67A2D28FCED849EE1BB76E7391B93EB12");
169        let version = proto::Version {
170            git_hash: git_hash.clone(),
171            ..base.clone()
172        };
173        assert_eq!(
174            Version::try_from(version),
175            Err(VersionError::GitHash(git_hash))
176        );
177
178        // Not all hex digits.
179        let git_hash = String::from("2fd4e1c67a2d28fced849ee1bb76e7391b93eb1g");
180        let version = proto::Version {
181            git_hash: git_hash.clone(),
182            ..base.clone()
183        };
184        assert_eq!(
185            Version::try_from(version),
186            Err(VersionError::GitHash(git_hash))
187        );
188
189        // Not all ascii.
190        let git_hash = String::from("2fd4e1c67a2d28fced849ee1bb76e7391b93eb1å");
191        let version = proto::Version {
192            git_hash: git_hash.clone(),
193            ..base.clone()
194        };
195        assert_eq!(
196            Version::try_from(version),
197            Err(VersionError::GitHash(git_hash))
198        );
199
200        // Valid.
201        let git_hash = String::from("2fd4e1c67a2d28fced849ee1bb76e7391b93eb12");
202        let version = proto::Version { git_hash, ..base };
203        assert!(Version::try_from(version).is_ok());
204    }
205
206    #[test]
207    fn producer() -> Result<(), VersionError> {
208        // Empty producer maps to `None`
209        let version = proto::Version {
210            producer: String::from(""),
211            ..version::version()
212        };
213        let version = Version::try_from(version)?;
214        assert!(version.producer.is_none());
215        Ok(())
216    }
217
218    #[test]
219    fn convert() -> Result<(), VersionError> {
220        let version = version::version();
221        assert_eq!(
222            proto::Version::from(Version::try_from(version.clone())?),
223            version
224        );
225        Ok(())
226    }
227
228    #[test]
229    fn compatible() -> Result<(), VersionError> {
230        let _version = Version::try_from(version::version())?;
231
232        let mut version = version::version();
233        version.major_number += 1;
234        let version = Version::try_from(version);
235        matches!(version, Err(VersionError::Substrait(_, _)));
236
237        let mut version = version::version();
238        version.minor_number += 1;
239        let version = Version::try_from(version);
240        matches!(version, Err(VersionError::Substrait(_, _)));
241
242        let mut version = version::version();
243        version.patch_number += 1;
244        let _version = Version::try_from(version)?;
245
246        Ok(())
247    }
248}