aquarium_control/utilities/version_information.rs
1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10#[cfg(not(test))]
11use log::info;
12
13use std::env;
14use std::fmt::{Display, Formatter};
15use std::num::ParseIntError;
16use thiserror::Error;
17
18/// Contains error definition for version information retrieval
19#[derive(Debug, Error)]
20pub enum VersionInformationError {
21 /// Major number could not be parsed from version string
22 #[error("Could not parse major number from version string ({0})")]
23 ParseErrorMajor(String),
24
25 /// Minor number could not be parsed from version string
26 #[error("Could not parse minor number from version string ({0})")]
27 ParseErrorMinor(String),
28
29 /// Build number could not be parsed from version string
30 #[error("Could not parse build number from version string ({0})")]
31 ParseErrorBuild(String),
32
33 /// Major number could not be converted to numeric value
34 #[error("Could not parse major number from version string ({version_string})")]
35 ConversionErrorMajor {
36 version_string: String,
37
38 #[source]
39 source: ParseIntError,
40 },
41
42 /// Minor number could not be converted to numeric value
43 #[error("Could not parse minor number from version string ({version_string})")]
44 ConversionErrorMinor {
45 version_string: String,
46
47 #[source]
48 source: ParseIntError,
49 },
50
51 /// Build number could not be converted to numeric value
52 #[error("Could not parse build number from version string ({version_string})")]
53 ConversionErrorBuild {
54 version_string: String,
55
56 #[source]
57 source: ParseIntError,
58 },
59
60 #[error("Failed to retrieve current executable path.")]
61 CurrentExecutablePathRetrievalFailure {
62 #[source]
63 source: std::io::Error,
64 },
65
66 #[error("Failed to read executable file content.")]
67 ExecutableFileReadFailure {
68 #[source]
69 source: std::io::Error,
70 },
71}
72
73/// Holds the data structure and implementation for retrieving and storing version information of the application.
74pub struct VersionInformation {
75 /// Major version number (used to identify major changes between releases)
76 pub major: i32,
77
78 /// Minor version number (used to identify minor changes between releases)
79 pub minor: i32,
80
81 /// Build number (increases with every published build)
82 pub build: i32,
83
84 /// Hash serves as a fingerprint to match SW with database configuration
85 pub hash: String,
86}
87
88impl VersionInformation {
89 /// Creates a new `VersionInformation` instance by reading the version from
90 /// `Cargo.toml` at compile time and calculating the SHA-256 hash of the
91 /// current executable.
92 ///
93 /// This is the primary constructor for production use. It performs the following steps:
94 /// 1. Retrieves the package version string (e.g., "1.2.3") from the `CARGO_PKG_VERSION`
95 /// environment variable, which Cargo sets at compile time.
96 /// 2. Determines the full path to the running executable.
97 /// 3. Reads the executable file's binary content.
98 /// 4. Calculates a SHA-256 hash of the binary content to serve as a unique fingerprint.
99 /// 5. Parses the version string and combines it with the hash.
100 ///
101 /// # Returns
102 /// A `Result` containing a new `VersionInformation` struct on success.
103 ///
104 /// # Errors
105 /// Returns a `VersionInformationError` if any part of the process fails:
106 /// - `VersionInformationError::CurrentExecutablePathRetrievalFailure`: If the path
107 /// to the current executable cannot be determined.
108 /// - `VersionInformationError::ExecutableFileReadFailure`: If the executable file
109 /// cannot be read from the filesystem, which could be due to a permissions issue.
110 /// - It will also propagate any parsing or conversion errors from the underlying
111 /// `from_string_and_hash` function if the version string from `Cargo.toml` is malformed.
112 pub fn new() -> Result<VersionInformation, VersionInformationError> {
113 let version_string = env!("CARGO_PKG_VERSION").to_string();
114
115 // Get the fully qualified path and file name.
116 let pathfilename = env::current_exe().map_err(|e| {
117 VersionInformationError::CurrentExecutablePathRetrievalFailure { source: e }
118 })?;
119
120 #[cfg(not(test))]
121 info!(
122 target: module_path!(),
123 "path of this executable is: {}",
124 pathfilename.display()
125 );
126
127 // Calculate hash from the executable.
128 let bytes = std::fs::read(&pathfilename)
129 .map_err(|e| VersionInformationError::ExecutableFileReadFailure { source: e })?;
130
131 let hash = sha256::digest(&bytes);
132
133 // Further process information in the testable function.
134 Self::from_string_and_hash(version_string, hash)
135 }
136
137 /// Creates a new `VersionInformation` instance from a version string and a hash.
138 ///
139 /// This function is designed to be used in testing environments, allowing for the injection
140 /// of arbitrary version strings and hashes to validate parsing and behavior.
141 ///
142 /// # Arguments
143 /// * `version_string` - A string slice representing the version (e.g., "1.2.3").
144 /// * `hash` - The `String` to use for the hash value.
145 ///
146 /// # Returns
147 /// A `Result` containing a new `VersionInformation` struct, or a `VersionInformationError`
148 /// if the version string cannot be parsed.
149 pub fn from_string_and_hash(
150 version_string: String,
151 hash: String,
152 ) -> Result<VersionInformation, VersionInformationError> {
153 // Split the "x.y.z" string into its parts, returning specific error on failure
154 let mut parts = version_string.split('.');
155 let major_str = match parts.next() {
156 Some(major_str) => major_str,
157 None => {
158 return Err(VersionInformationError::ParseErrorMajor(version_string));
159 }
160 };
161
162 let minor_str = match parts.next() {
163 Some(minor_str) => minor_str,
164 None => {
165 return Err(VersionInformationError::ParseErrorMinor(version_string));
166 }
167 };
168
169 // The patch/build number might have suffixes like "-alpha", so we take only the numeric part.
170 let build_str = match parts.next() {
171 Some(build_part) => match build_part.split('-').next() {
172 Some(build_str) => build_str,
173 None => {
174 return Err(VersionInformationError::ParseErrorBuild(version_string));
175 }
176 },
177 None => {
178 return Err(VersionInformationError::ParseErrorBuild(version_string));
179 }
180 };
181
182 // Parse each part into an i32, returning a specific error on failure.
183 let major = major_str.parse::<i32>().map_err(|e| {
184 VersionInformationError::ConversionErrorMajor {
185 version_string: major_str.to_string(),
186 source: e,
187 }
188 })?;
189
190 let minor = minor_str.parse::<i32>().map_err(|e| {
191 VersionInformationError::ConversionErrorMinor {
192 version_string: minor_str.to_string(),
193 source: e,
194 }
195 })?;
196
197 let build = build_str.parse::<i32>().map_err(|e| {
198 VersionInformationError::ConversionErrorBuild {
199 version_string: build_str.to_string(),
200 source: e,
201 }
202 })?;
203
204 Ok(VersionInformation {
205 major,
206 minor,
207 build,
208 hash,
209 })
210 }
211}
212
213impl Display for VersionInformation {
214 /// Formats the `VersionInformation` for display.
215 ///
216 /// This implementation provides a human-readable, multi-line representation of the
217 /// version details, including major, minor, build numbers, and the executable hash.
218 ///
219 /// # Arguments
220 /// * `f` - A mutable reference to a `Formatter` where the output will be written.
221 ///
222 /// # Returns
223 /// An `Ok(())` on successful formatting.
224 ///
225 /// # Errors
226 /// Returns an `Err` containing a `std::fmt::Error` if an I/O error occurs
227 /// while writing to the formatter.
228 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
229 writeln!(f, "Version information:")?;
230 writeln!(f, " Major: {}", self.major)?;
231 writeln!(f, " Minor: {}", self.minor)?;
232 writeln!(f, " Build: {}", self.build)?;
233 writeln!(f, " Hash: {}", self.hash)?;
234 Ok(())
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn dummy_hash() -> String {
243 "dummy_hash_for_testing".to_string()
244 }
245
246 #[test]
247 fn test_valid_version_string() {
248 let info =
249 VersionInformation::from_string_and_hash("1.2.3".to_string(), dummy_hash()).unwrap();
250 assert_eq!(info.major, 1);
251 assert_eq!(info.minor, 2);
252 assert_eq!(info.build, 3);
253 }
254
255 #[test]
256 fn test_version_string_with_prerelease_tag() {
257 let info =
258 VersionInformation::from_string_and_hash("0.4.5-beta.1".to_string(), dummy_hash())
259 .unwrap();
260 assert_eq!(info.major, 0);
261 assert_eq!(info.minor, 4);
262 assert_eq!(info.build, 5);
263 }
264
265 #[test]
266 fn test_invalid_major_version() {
267 let result = VersionInformation::from_string_and_hash("a.2.3".to_string(), dummy_hash());
268 assert!(matches!(
269 result,
270 Err(VersionInformationError::ConversionErrorMajor { .. })
271 ));
272 }
273
274 #[test]
275 fn test_invalid_minor_version() {
276 let result = VersionInformation::from_string_and_hash("1.a.3".to_string(), dummy_hash());
277 assert!(matches!(
278 result,
279 Err(VersionInformationError::ConversionErrorMinor { .. })
280 ));
281 }
282
283 #[test]
284 fn test_invalid_build_version() {
285 let result = VersionInformation::from_string_and_hash("1.2.x".to_string(), dummy_hash());
286 assert!(matches!(
287 result,
288 Err(VersionInformationError::ConversionErrorBuild { .. })
289 ));
290 }
291
292 #[test]
293 fn test_incomplete_version_string() {
294 let result = VersionInformation::from_string_and_hash("1.2".to_string(), dummy_hash());
295 assert!(matches!(
296 result,
297 Err(VersionInformationError::ParseErrorBuild { .. })
298 ));
299 }
300}