winbrew_models\shared/
version.rs1use core::fmt;
9use core::str::FromStr;
10
11use semver as semver_crate;
12use serde::{Deserialize, Serialize};
13
14use super::error::ModelError;
15use super::validation::Validate;
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct Version(semver_crate::Version);
21
22impl Version {
23 pub fn new(major: u64, minor: u64, patch: u64) -> Self {
25 Self(semver_crate::Version::new(major, minor, patch))
26 }
27
28 pub fn parse(value: &str) -> Result<Self, ModelError> {
30 value.parse()
31 }
32
33 pub fn parse_lossy(value: &str) -> Result<Self, ModelError> {
35 match Self::parse(value) {
36 Ok(version) => Ok(version),
37 Err(strict_err) => {
38 let normalized = match normalize_lossy_version(value) {
39 Some(normalized) => normalized,
40 None => return Err(strict_err),
41 };
42
43 semver_crate::Version::parse(&normalized)
44 .map(Self)
45 .map_err(|err| {
46 ModelError::invalid_version(
47 value,
48 format!(
49 "{strict_err}; normalized to {normalized}, but parsing still failed: {err}"
50 ),
51 )
52 })
53 }
54 }
55 }
56
57 pub fn as_semver(&self) -> &semver_crate::Version {
58 &self.0
59 }
60}
61
62impl FromStr for Version {
63 type Err = ModelError;
64
65 fn from_str(s: &str) -> Result<Self, Self::Err> {
66 semver_crate::Version::parse(s)
67 .map(Self)
68 .map_err(|err| ModelError::invalid_version(s, err.to_string()))
69 }
70}
71
72impl fmt::Display for Version {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 fmt::Display::fmt(&self.0, f)
75 }
76}
77
78impl From<semver_crate::Version> for Version {
79 fn from(value: semver_crate::Version) -> Self {
80 Self(value)
81 }
82}
83
84impl From<Version> for semver_crate::Version {
85 fn from(value: Version) -> Self {
86 value.0
87 }
88}
89
90impl From<Version> for String {
91 fn from(value: Version) -> Self {
92 value.to_string()
93 }
94}
95
96fn normalize_lossy_version(value: &str) -> Option<String> {
97 let trimmed = value.trim();
98 if trimmed.is_empty() {
99 return None;
100 }
101
102 let trimmed = strip_version_prefix(trimmed);
103 let tokens = tokenize_version(trimmed);
104 if tokens.is_empty() {
105 return None;
106 }
107
108 if tokens.first().is_none_or(|token| !starts_with_digit(token)) {
109 return Some(format!("0.0.0-{}", join_identifiers(tokens.iter())));
110 }
111
112 let mut core = Vec::with_capacity(3);
113 let mut extra = Vec::new();
114 let mut has_non_numeric_extra = false;
115
116 for token in tokens {
117 if token.is_empty() {
118 continue;
119 }
120
121 if core.len() < 3 {
122 if token.chars().all(|ch| ch.is_ascii_digit()) {
123 core.push(normalize_numeric_identifier(token));
124 continue;
125 }
126
127 if let Some((digits, suffix)) = split_numeric_prefix(token) {
128 core.push(normalize_numeric_identifier(digits));
129 if !suffix.is_empty() {
130 extra.push(suffix.to_string());
131 has_non_numeric_extra = true;
132 }
133 continue;
134 }
135
136 extra.push(token.to_string());
137 has_non_numeric_extra = true;
138 continue;
139 }
140
141 if token.chars().all(|ch| ch.is_ascii_digit()) {
142 extra.push(normalize_numeric_identifier(token));
143 } else {
144 extra.push(token.to_string());
145 has_non_numeric_extra = true;
146 }
147 }
148
149 while core.len() < 3 {
150 core.push(String::from("0"));
151 }
152
153 let mut normalized = core.join(".");
154 if !extra.is_empty() {
155 normalized.push(if has_non_numeric_extra { '-' } else { '+' });
156 normalized.push_str(&extra.join("."));
157 }
158
159 Some(normalized)
160}
161
162fn strip_version_prefix(value: &str) -> &str {
163 if let Some(stripped) = value
164 .strip_prefix('v')
165 .or_else(|| value.strip_prefix('V'))
166 .filter(|rest| rest.chars().next().is_some_and(|ch| ch.is_ascii_digit()))
167 {
168 stripped
169 } else {
170 value
171 }
172}
173
174fn tokenize_version(value: &str) -> Vec<&str> {
175 value
176 .split(|ch: char| !ch.is_ascii_alphanumeric())
177 .filter(|token| !token.is_empty())
178 .collect()
179}
180
181fn join_identifiers<'a, I>(tokens: I) -> String
182where
183 I: IntoIterator<Item = &'a &'a str>,
184{
185 tokens
186 .into_iter()
187 .map(|token| {
188 if token.chars().all(|ch| ch.is_ascii_digit()) {
189 normalize_numeric_identifier(token)
190 } else {
191 (*token).to_string()
192 }
193 })
194 .collect::<Vec<_>>()
195 .join(".")
196}
197
198fn normalize_numeric_identifier(value: &str) -> String {
199 let trimmed = value.trim_start_matches('0');
200 if trimmed.is_empty() {
201 String::from("0")
202 } else {
203 trimmed.to_string()
204 }
205}
206
207fn split_numeric_prefix(value: &str) -> Option<(&str, &str)> {
208 let digits = value
209 .bytes()
210 .take_while(|byte| byte.is_ascii_digit())
211 .count();
212
213 if digits == 0 {
214 return None;
215 }
216
217 Some(value.split_at(digits))
218}
219
220fn starts_with_digit(value: &str) -> bool {
221 value.chars().next().is_some_and(|ch| ch.is_ascii_digit())
222}
223
224impl Validate for Version {
225 fn validate(&self) -> Result<(), ModelError> {
226 Ok(())
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::Version;
233
234 #[test]
235 fn parses_semver_and_orders_versions() {
236 let version = Version::parse("1.2.3").expect("version should parse");
237 let newer = Version::parse("1.2.4").expect("version should parse");
238
239 assert!(newer > version);
240 assert_eq!(version.to_string(), "1.2.3");
241 }
242
243 #[test]
244 fn parses_common_winget_versions_lossily() {
245 let cases = [
246 ("v2.6.0", "2.6.0"),
247 ("2026.03.17", "2026.3.17"),
248 ("4.0", "4.0.0"),
249 ("115.0.5790.136", "115.0.5790+136"),
250 ("20240608.083822.1ed9031", "20240608.83822.1-ed9031"),
251 (
252 "N-123778-g3b55818764-20260331",
253 "0.0.0-N.123778.g3b55818764.20260331",
254 ),
255 ];
256
257 for (input, expected) in cases {
258 let parsed = Version::parse_lossy(input).expect("version should parse lossy");
259 assert_eq!(parsed.to_string(), expected);
260 }
261 }
262}