unity_version/version/
mod.rs

1use crate::sys::version as version_impl;
2use derive_more::Display;
3use nom::{
4    branch::alt,
5    character::complete::{char, digit1, hex_digit1, space1},
6    combinator::{map_res, verify},
7    error::context,
8    sequence::delimited,
9    IResult, Parser,
10};
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use std::path::{Path, PathBuf};
13use std::{cmp::Ordering, str::FromStr, sync::OnceLock};
14use regex::Regex;
15
16mod release_type;
17mod revision_hash;
18use crate::error::VersionError;
19pub use release_type::ReleaseType;
20pub use revision_hash::RevisionHash;
21
22#[derive(Eq, Debug, Clone, Hash, PartialOrd, Display)]
23#[display("{}{}{}", base, release_type, revision)]
24pub struct Version {
25    base: semver::Version,
26    release_type: ReleaseType,
27    revision: u64,
28}
29
30impl Serialize for Version {
31    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
32    where
33        S: Serializer,
34    {
35        let s = self.to_string();
36        serializer.serialize_str(&s)
37    }
38}
39
40impl<'de> Deserialize<'de> for Version {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        let s = String::deserialize(deserializer)?;
46        Version::from_str(&s).map_err(serde::de::Error::custom)
47    }
48}
49
50impl Ord for Version {
51    fn cmp(&self, other: &Version) -> Ordering {
52        self.base
53            .cmp(&other.base)
54            .then(self.release_type.cmp(&other.release_type))
55            .then(self.revision.cmp(&other.revision))
56    }
57}
58
59impl PartialEq for Version {
60    fn eq(&self, other: &Self) -> bool {
61        self.base == other.base
62            && self.release_type == other.release_type
63            && self.revision == other.revision
64    }
65}
66
67impl AsRef<Version> for Version {
68    fn as_ref(&self) -> &Self {
69        self
70    }
71}
72
73impl AsMut<Version> for Version {
74    fn as_mut(&mut self) -> &mut Self {
75        self
76    }
77}
78
79impl FromStr for Version {
80    type Err = VersionError;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        match parse_version(s) {
84            Ok((_, version)) => Ok(version),
85            Err(err) => {
86                let error_msg = match err {
87                    nom::Err::Error(e) | nom::Err::Failure(e) => {
88                        format!("Parse error at: {}", e.input)
89                    },
90                    _ => "Unknown parsing error".to_string(),
91                };
92                Err(VersionError::ParsingFailed(error_msg))
93            }
94        }
95    }
96}
97
98impl TryFrom<&str> for Version {
99    type Error = <Version as FromStr>::Err;
100
101    fn try_from(value: &str) -> Result<Self, Self::Error> {
102        Version::from_str(value)
103    }
104}
105
106impl TryFrom<String> for Version {
107    type Error = <Version as FromStr>::Err;
108
109    fn try_from(value: String) -> Result<Self, Self::Error> {
110        Version::from_str(&value)
111    }
112}
113
114impl TryFrom<PathBuf> for Version {
115    type Error = VersionError;
116
117    fn try_from(path: PathBuf) -> Result<Self, VersionError> {
118        Version::from_path(path)
119    }
120}
121
122impl TryFrom<&Path> for Version {
123    type Error = VersionError;
124
125    fn try_from(path: &Path) -> Result<Self, VersionError> {
126        Version::from_path(path)
127    }
128}
129
130impl Version {
131    pub fn new(
132        major: u64,
133        minor: u64,
134        patch: u64,
135        release_type: ReleaseType,
136        revision: u64,
137    ) -> Version {
138        let base = semver::Version::new(major, minor, patch);
139        Version {
140            base,
141            release_type,
142            revision,
143        }
144    }
145
146    pub fn release_type(&self) -> ReleaseType {
147        self.release_type
148    }
149
150    pub fn major(&self) -> u64 {
151        self.base.major
152    }
153
154    pub fn minor(&self) -> u64 {
155        self.base.minor
156    }
157
158    pub fn patch(&self) -> u64 {
159        self.base.patch
160    }
161
162    pub fn revision(&self) -> u64 {
163        self.revision
164    }
165
166    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, VersionError> {
167        version_impl::read_version_from_path(path)
168    }
169
170    pub fn from_string_containing<S: AsRef<str>>(s: S) -> Result<Self, VersionError> {
171        static VERSION_REGEX: OnceLock<Regex> = OnceLock::new();
172        let regex = VERSION_REGEX.get_or_init(|| {
173            Regex::new(r"\b\d+\.\d+\.\d+[fabp]\d+\b").unwrap()
174        });
175        
176        let s = s.as_ref();
177        
178        for mat in regex.find_iter(s) {
179            if let Ok(version) = Version::from_str(mat.as_str()) {
180                return Ok(version);
181            }
182        }
183            Err(VersionError::ParsingFailed(format!("Could not find a valid Unity version in string: {}", s)))
184    }
185
186    pub fn base(&self) -> &semver::Version {
187        &self.base
188    }
189}
190
191#[derive(Eq, Debug, Clone, Hash, Display)]
192#[display("{} ({})", version, revision)]
193#[allow(dead_code)]
194pub struct CompleteVersion {
195    version: Version,
196    revision: RevisionHash,
197}
198
199impl CompleteVersion {
200    /// Creates a new CompleteVersion from a Version and RevisionHash.
201    pub fn new(version: Version, revision: RevisionHash) -> Self {
202        Self { version, revision }
203    }
204    
205    /// Gets the version component.
206    pub fn version(&self) -> &Version {
207        &self.version
208    }
209    
210    /// Gets the revision hash component.
211    pub fn revision(&self) -> &RevisionHash {
212        &self.revision
213    }
214}
215
216impl FromStr for CompleteVersion {
217    type Err = VersionError;
218
219    /// Parses a complete Unity version string with revision hash.
220    /// 
221    /// # Format
222    /// 
223    /// Expects format: "VERSION (REVISION_HASH)"
224    /// Examples: "2021.3.55f1 (f87d5274e360)", "2022.1.5f1 (abc123def456)"
225    /// 
226    /// # Errors
227    /// 
228    /// Returns an error if:
229    /// - No revision hash is present
230    /// - Version format is invalid  
231    /// - Revision hash format is invalid
232    fn from_str(s: &str) -> Result<Self, Self::Err> {
233        match parse_complete_version(s.trim()) {
234            Ok((remaining, complete_version)) => {
235                if remaining.is_empty() {
236                    Ok(complete_version)
237                } else {
238                    Err(VersionError::ParsingFailed(
239                        format!("Unexpected remaining input: '{}'", remaining)
240                    ))
241                }
242            }
243            Err(err) => {
244                let error_msg = match err {
245                    nom::Err::Error(e) | nom::Err::Failure(e) => {
246                        format!("Parse error at: '{}'", e.input)
247                    }
248                    nom::Err::Incomplete(_) => {
249                        "Incomplete input".to_string()
250                    }
251                };
252                Err(VersionError::ParsingFailed(error_msg))
253            }
254        }
255    }
256}
257
258impl PartialEq for CompleteVersion {
259    fn eq(&self, other: &Self) -> bool {
260        self.version == other.version
261    }
262}
263
264impl PartialOrd for CompleteVersion {
265    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
266        self.version.partial_cmp(&other.version)
267    }
268}
269
270fn parse_release_type(input: &str) -> IResult<&str, ReleaseType> {
271    context(
272        "release type",
273        map_res(alt((char('f'), char('b'), char('a'), char('p'))), |c| {
274            ReleaseType::try_from(c)
275        }),
276    ).parse(input)
277}
278
279fn parse_version(input: &str) -> IResult<&str, Version> {
280    context(
281        "version",
282        (
283            context("major version", map_res(digit1, |s: &str| s.parse::<u64>())),
284            char('.'),
285            context("minor version", map_res(digit1, |s: &str| s.parse::<u64>())),
286            char('.'),
287            context("patch version", map_res(digit1, |s: &str| s.parse::<u64>())),
288            parse_release_type,
289            context("revision", map_res(digit1, |s: &str| s.parse::<u64>())),
290        )
291    )
292    .map(|(major, _, minor, _, patch, release_type, revision)| {
293        let base = semver::Version::new(major, minor, patch);
294        Version {
295            base,
296            release_type,
297            revision,
298        }
299    })
300    .parse(input)
301}
302
303fn parse_revision_hash(input: &str) -> IResult<&str, RevisionHash> {
304    context(
305        "revision hash",
306        map_res(
307            verify(hex_digit1, |s: &str| s.len() == 12),
308            |hex_str: &str| RevisionHash::new(hex_str)
309        )
310    ).parse(input)
311}
312
313fn parse_complete_version(input: &str) -> IResult<&str, CompleteVersion> {
314    context(
315        "complete version",
316        (
317            parse_version,
318            space1,
319            delimited(char('('), parse_revision_hash, char(')')),
320        )
321    )
322    .map(|(version, _, revision)| CompleteVersion::new(version, revision))
323    .parse(input)
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use proptest::prelude::*;
330
331    #[test]
332    fn parse_version_string_with_valid_input() {
333        let version_string = "1.2.3f4";
334        let version = Version::from_str(version_string);
335        assert!(version.is_ok(), "valid input returns a version")
336    }
337
338    #[test]
339    fn splits_version_string_into_components() {
340        let version_string = "11.2.3f4";
341        let version = Version::from_str(version_string).unwrap();
342
343        assert_eq!(version.base.major, 11, "parse correct major component");
344        assert_eq!(version.base.minor, 2, "parse correct minor component");
345        assert_eq!(version.base.patch, 3, "parse correct patch component");
346
347        assert_eq!(version.release_type, ReleaseType::Final);
348        assert_eq!(version.revision, 4, "parse correct revision component");
349    }
350
351    #[test]
352    fn test_complete_version_from_str() {
353        // Test successful parsing
354        let complete_version = CompleteVersion::from_str("2021.3.55f1 (f87d5274e360)").unwrap();
355        assert_eq!(complete_version.version().to_string(), "2021.3.55f1");
356        assert_eq!(complete_version.revision().as_str(), "f87d5274e360");
357        assert_eq!(complete_version.to_string(), "2021.3.55f1 (f87d5274e360)");
358
359        // Test different version formats
360        let alpha_version = CompleteVersion::from_str("2023.1.0a1 (123456789abc)").unwrap();
361        assert_eq!(alpha_version.version().to_string(), "2023.1.0a1");
362        assert_eq!(alpha_version.revision().as_str(), "123456789abc");
363
364        // Test error cases with specific error message validation
365        
366        // No revision hash
367        let no_hash_result = CompleteVersion::from_str("2021.3.55f1");
368        assert!(no_hash_result.is_err());
369        let error_msg = no_hash_result.unwrap_err().to_string();
370        assert!(error_msg.contains("Parse error"), "Expected parsing error for missing hash, got: {}", error_msg);
371        
372        // Invalid version format
373        let invalid_version_result = CompleteVersion::from_str("invalid (f87d5274e360)");
374        assert!(invalid_version_result.is_err());
375        let error_msg = invalid_version_result.unwrap_err().to_string();
376        assert!(error_msg.contains("Parse error"), "Expected parsing error for invalid version, got: {}", error_msg);
377        
378        // Invalid hash characters
379        let invalid_hash_result = CompleteVersion::from_str("2021.3.55f1 (invalid)");
380        assert!(invalid_hash_result.is_err());
381        let error_msg = invalid_hash_result.unwrap_err().to_string();
382        assert!(error_msg.contains("Invalid revision hash") || error_msg.contains("Parse error"), 
383                "Expected revision hash error, got: {}", error_msg);
384        
385        // Hash too short
386        let short_hash_result = CompleteVersion::from_str("2021.3.55f1 (f87d527)");
387        assert!(short_hash_result.is_err());
388        let error_msg = short_hash_result.unwrap_err().to_string();
389        assert!(error_msg.contains("Invalid revision hash") || error_msg.contains("Parse error"), 
390                "Expected revision hash error for short hash, got: {}", error_msg);
391        
392        // Hash too long
393        let long_hash_result = CompleteVersion::from_str("2021.3.55f1 (f87d5274e360ab)");
394        assert!(long_hash_result.is_err());
395        let error_msg = long_hash_result.unwrap_err().to_string();
396        assert!(error_msg.contains("Parse error"), "Expected parsing error for long hash, got: {}", error_msg);
397        
398        // Non-hex characters in hash
399        let non_hex_result = CompleteVersion::from_str("2021.3.55f1 (f87d5274e36z)");
400        assert!(non_hex_result.is_err());
401        let error_msg = non_hex_result.unwrap_err().to_string();
402        assert!(error_msg.contains("Invalid revision hash") || error_msg.contains("Parse error"), 
403                "Expected revision hash error for non-hex chars, got: {}", error_msg);
404    }
405
406    #[test]
407    fn extracts_version_from_text() {
408        let text = "Some text before 2023.1.4f5 and some after";
409        let result = Version::from_string_containing(text);
410        assert!(result.is_ok(), "Should successfully extract the version");
411
412        let version = result.unwrap();
413        assert_eq!(version.base.major, 2023);
414        assert_eq!(version.base.minor, 1);
415        assert_eq!(version.base.patch, 4);
416        assert_eq!(version.release_type, ReleaseType::Final);
417        assert_eq!(version.revision, 5);
418    }
419
420
421    #[test]
422    fn extracts_version_from_text_and_returns_first_complete_version() {
423        let text = "Some text 23 before 2023.1.4f5 and some after";
424        let result = Version::from_string_containing(text);
425        assert!(result.is_ok(), "Should successfully extract the version");
426
427        let version = result.unwrap();
428        assert_eq!(version.base.major, 2023);
429        assert_eq!(version.base.minor, 1);
430        assert_eq!(version.base.patch, 4);
431        assert_eq!(version.release_type, ReleaseType::Final);
432        assert_eq!(version.revision, 5);
433    }
434
435    proptest! {
436        #[test]
437        fn from_str_does_not_crash(s in "\\PC*") {
438            let _v = Version::from_str(&s);
439        }
440
441        #[test]
442        fn from_str_supports_all_valid_cases(
443            major in 0u64..=u64::MAX,
444            minor in 0u64..=u64::MAX,
445            patch in 0u64..=u64::MAX,
446            release_type in prop_oneof!["f", "p", "b", "a"],
447            revision in 0u64..=u64::MAX,
448        ) {
449            let version_string = format!("{}.{}.{}{}{}", major, minor, patch, release_type, revision);
450            let version = Version::from_str(&version_string).unwrap();
451
452            assert!(version.base.major == major, "parse correct major component");
453            assert!(version.base.minor == minor, "parse correct minor component");
454            assert!(version.base.patch == patch, "parse correct patch component");
455
456            assert_eq!(version.release_type, ReleaseType::from_str(&release_type).unwrap());
457            assert!(version.revision == revision, "parse correct revision component");
458        }
459    }
460}