Skip to main content

asdf_core/
version.rs

1//! Version strings, as they appear on the `#ASDF` header line, in tags, and
2//! in `core/software` metadata.
3
4use core::fmt;
5
6/// A parsed version string.
7///
8/// Mirrors `asdf_version_t`, including its tolerance for versions that are not
9/// `MAJOR.MINOR.PATCH`: those keep the full string and leave the numeric
10/// fields at zero.
11#[derive(Clone, PartialEq, Eq, Debug, Default)]
12pub struct Version {
13    /// The full, unparsed version string.
14    pub version: String,
15    /// Major version, or 0 if the string was not `X.Y.Z`.
16    pub major: u32,
17    /// Minor version, or 0.
18    pub minor: u32,
19    /// Patch version, or 0.
20    pub patch: u32,
21    /// Trailing version information, if any.
22    ///
23    /// A separator following a complete `X.Y.Z` is dropped, so `1.2.3-rc1`
24    /// and `1.2.3.dev4` both yield `rc1` / `dev4`. A separator appearing
25    /// earlier is kept verbatim, matching upstream.
26    pub extra: Option<String>,
27}
28
29/// Consume a run of ASCII digits, returning the value and the rest.
30fn take_u32(s: &str) -> Option<(u32, &str)> {
31    let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
32    if end == 0 {
33        return None;
34    }
35    // Saturate rather than fail: C's strtoul clamps at ULONG_MAX, and a
36    // version component that large is malformed either way.
37    let value = s[..end].parse::<u32>().unwrap_or(u32::MAX);
38    Some((value, &s[end..]))
39}
40
41impl Version {
42    /// Parse a version string.
43    ///
44    /// This never fails; an unparseable string is preserved verbatim with
45    /// zeroed numeric fields, exactly as `asdf_version_parse` behaves.
46    pub fn parse(version: &str) -> Self {
47        let mut out = Version { version: version.to_string(), ..Default::default() };
48
49        // Not a semver at all; keep the string and stop.
50        let Some((major, rest)) = take_u32(version) else {
51            return out;
52        };
53        out.major = major;
54
55        let Some(rest) = rest.strip_prefix('.') else {
56            if !rest.is_empty() {
57                out.extra = Some(rest.to_string());
58            }
59            return out;
60        };
61
62        let Some((minor, rest)) = take_u32(rest) else {
63            out.extra = Some(rest.to_string());
64            return out;
65        };
66        out.minor = minor;
67
68        let Some(rest) = rest.strip_prefix('.') else {
69            if !rest.is_empty() {
70                out.extra = Some(rest.to_string());
71            }
72            return out;
73        };
74
75        let Some((patch, rest)) = take_u32(rest) else {
76            out.extra = Some(rest.to_string());
77            return out;
78        };
79        out.patch = patch;
80
81        // Only after a complete X.Y.Z is a single separator absorbed.
82        if !rest.is_empty() {
83            let tail = rest.strip_prefix(['.', '-']).unwrap_or(rest);
84            if !tail.is_empty() {
85                out.extra = Some(tail.to_string());
86            }
87        }
88        out
89    }
90
91    /// Build a version from its numeric components.
92    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
93        Self { version: format!("{major}.{minor}.{patch}"), major, minor, patch, extra: None }
94    }
95
96    /// The `(major, minor, patch)` triple, for ordering comparisons.
97    pub fn triple(&self) -> (u32, u32, u32) {
98        (self.major, self.minor, self.patch)
99    }
100}
101
102impl fmt::Display for Version {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(&self.version)
105    }
106}
107
108/// The ASDF low-level file format version this library writes.
109pub const ASDF_FORMAT_VERSION: &str = "1.0.0";
110
111/// The ASDF Standard version this library writes by default.
112pub const ASDF_STANDARD_VERSION: &str = "1.6.0";
113
114/// The ASDF Standard versions this library knows about.
115pub const SUPPORTED_STANDARD_VERSIONS: &[&str] =
116    &["1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0", "1.5.0", "1.6.0"];
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parses_plain_semver() {
124        let v = Version::parse("1.6.0");
125        assert_eq!(v.triple(), (1, 6, 0));
126        assert_eq!(v.extra, None);
127        assert_eq!(v.version, "1.6.0");
128    }
129
130    #[test]
131    fn separator_after_patch_is_absorbed() {
132        // Both PEP-440's dot and semver's hyphen are dropped after X.Y.Z.
133        let v = Version::parse("0.1.0.dev4");
134        assert_eq!(v.triple(), (0, 1, 0));
135        assert_eq!(v.extra.as_deref(), Some("dev4"));
136
137        let v = Version::parse("1.2.3-rc1");
138        assert_eq!(v.triple(), (1, 2, 3));
139        assert_eq!(v.extra.as_deref(), Some("rc1"));
140    }
141
142    #[test]
143    fn other_suffixes_are_kept_verbatim() {
144        // No separator to absorb, so the suffix arrives whole.
145        let v = Version::parse("0.1.0rc2");
146        assert_eq!(v.triple(), (0, 1, 0));
147        assert_eq!(v.extra.as_deref(), Some("rc2"));
148    }
149
150    #[test]
151    fn separator_before_patch_is_kept() {
152        // Upstream only strips the separator after a *complete* X.Y.Z, so a
153        // hyphen appearing earlier survives into `extra`.
154        let v = Version::parse("1.2-foo");
155        assert_eq!(v.triple(), (1, 2, 0));
156        assert_eq!(v.extra.as_deref(), Some("-foo"));
157    }
158
159    #[test]
160    fn truncated_versions_zero_the_rest() {
161        let v = Version::parse("1");
162        assert_eq!(v.triple(), (1, 0, 0));
163        assert_eq!(v.extra, None);
164
165        let v = Version::parse("1.2");
166        assert_eq!(v.triple(), (1, 2, 0));
167        assert_eq!(v.extra, None);
168    }
169
170    #[test]
171    fn non_numeric_is_preserved_verbatim() {
172        let v = Version::parse("not-a-version");
173        assert_eq!(v.triple(), (0, 0, 0));
174        assert_eq!(v.version, "not-a-version");
175        assert_eq!(v.extra, None);
176    }
177
178    #[test]
179    fn missing_component_becomes_extra() {
180        let v = Version::parse("1.x");
181        assert_eq!(v.triple(), (1, 0, 0));
182        assert_eq!(v.extra.as_deref(), Some("x"));
183    }
184
185    #[test]
186    fn empty_string() {
187        let v = Version::parse("");
188        assert_eq!(v.triple(), (0, 0, 0));
189        assert_eq!(v.version, "");
190    }
191
192    #[test]
193    fn display_round_trips_the_original() {
194        for s in ["1.6.0", "0.1.0rc2", "not-a-version", "1.2.3-rc1"] {
195            assert_eq!(Version::parse(s).to_string(), s);
196        }
197    }
198}