winbrew_models\shared/
version.rs

1//! Semver-backed version values with WinGet-friendly normalization.
2//!
3//! The `Version` wrapper keeps version parsing and display centralized so the
4//! rest of the model layer can work with one canonical representation. Strict
5//! parsing uses semantic version rules; lossy parsing accepts common WinGet and
6//! package-feed variants.
7
8use 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/// A semantic version wrapper used throughout the model layer.
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct Version(semver_crate::Version);
21
22impl Version {
23    /// Build a version from explicit major, minor, and patch components.
24    pub fn new(major: u64, minor: u64, patch: u64) -> Self {
25        Self(semver_crate::Version::new(major, minor, patch))
26    }
27
28    /// Parse a strict semantic version string.
29    pub fn parse(value: &str) -> Result<Self, ModelError> {
30        value.parse()
31    }
32
33    /// Parses a version string, accepting common Winget-style variants.
34    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}