Skip to main content

qualia_client_core/
qapp_version.rs

1//! Semver-style version parsing and comparison for `qapp.json` `version` fields.
2//!
3//! Supports `major.minor.patch` with optional `-pre` suffix (e.g. `0.0.12-dev`).
4//! A stable release (`0.0.12`) sorts newer than the same numeric pre-release (`0.0.12-dev`).
5
6use std::cmp::Ordering;
7
8/// Parsed qapp manifest version.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ParsedVersion {
11    pub major: u32,
12    pub minor: u32,
13    pub patch: u32,
14    /// `None` = stable; `Some(label)` = pre-release (lower than stable at same numbers).
15    pub pre_release: Option<String>,
16}
17
18/// Normalize user-facing version text (`v0.0.12-dev`, ` 0.0.12 `).
19pub fn normalize_version_label(raw: &str) -> String {
20    let trimmed = raw.trim().trim_start_matches('v').trim();
21    let core = trimmed.split('+').next().unwrap_or(trimmed);
22    core.to_string()
23}
24
25/// Parse a version string; returns `None` when no numeric triple is found.
26pub fn parse_version(raw: &str) -> Option<ParsedVersion> {
27    let label = normalize_version_label(raw);
28    if label.is_empty() {
29        return None;
30    }
31
32    let (numeric, pre) = match label.split_once('-') {
33        Some((n, p)) if !p.is_empty() => (n, Some(p.to_string())),
34        _ => (label.as_str(), None),
35    };
36
37    let mut parts = numeric.split('.');
38    let major = parts.next()?.parse().ok()?;
39    let minor = parts.next().unwrap_or("0").parse().ok()?;
40    let patch = parts.next().unwrap_or("0").parse().ok()?;
41
42    Some(ParsedVersion {
43        major,
44        minor,
45        patch,
46        pre_release: pre,
47    })
48}
49
50pub fn compare_parsed(a: &ParsedVersion, b: &ParsedVersion) -> Ordering {
51    (a.major, a.minor, a.patch)
52        .cmp(&(b.major, b.minor, b.patch))
53        .then_with(|| compare_pre_release(&a.pre_release, &b.pre_release))
54}
55
56fn compare_pre_release(a: &Option<String>, b: &Option<String>) -> Ordering {
57    match (a, b) {
58        (None, None) => Ordering::Equal,
59        (None, Some(_)) => Ordering::Greater,
60        (Some(_), None) => Ordering::Less,
61        (Some(a), Some(b)) => a.cmp(b),
62    }
63}
64
65/// Compare two version labels. Returns `None` if either side fails to parse.
66pub fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
67    let pa = parse_version(a)?;
68    let pb = parse_version(b)?;
69    Some(compare_parsed(&pa, &pb))
70}
71
72/// True when `candidate` is strictly newer than `installed`.
73pub fn is_version_newer(candidate: &str, installed: &str) -> bool {
74    matches!(
75        compare_versions(candidate, installed),
76        Some(Ordering::Greater)
77    )
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn stable_beats_prerelease_at_same_numbers() {
86        assert!(is_version_newer("0.0.12", "0.0.12-dev"));
87        assert!(!is_version_newer("0.0.12-dev", "0.0.12"));
88    }
89
90    #[test]
91    fn patch_bump_is_newer() {
92        assert!(is_version_newer("0.0.13", "0.0.12"));
93        assert!(!is_version_newer("0.0.12", "0.0.13"));
94    }
95
96    #[test]
97    fn v_prefix_and_whitespace() {
98        use std::cmp::Ordering;
99
100        assert!(parse_version(" v0.0.12 ").is_some());
101        assert!(!is_version_newer("v0.0.12", "0.0.12"));
102        assert_eq!(compare_versions("v0.0.12", "0.0.12"), Some(Ordering::Equal));
103    }
104
105    #[test]
106    fn equal_versions_not_newer() {
107        assert!(!is_version_newer("0.0.12", "0.0.12"));
108        assert!(!is_version_newer("0.0.12-dev", "0.0.12-dev"));
109    }
110}