Skip to main content

backhopper_core/model/
release_version.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Release versions of a host and of Erlang/OTP: one parser, separate
6//! types for the two meanings.
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::config::Project;
15use crate::model::names::TagName;
16
17const MIN_SEGMENTS: usize = 2;
18const MAX_SEGMENTS: usize = 6;
19
20#[derive(Debug, Clone, PartialEq, Eq, Error)]
21pub enum ReleaseVersionError {
22    #[error("release version is empty")]
23    Empty,
24
25    #[error(
26        "release version {value:?} has {count} segments: needs {MIN_SEGMENTS} to {MAX_SEGMENTS}"
27    )]
28    SegmentCount { value: String, count: usize },
29
30    #[error("release version {value:?} has a non-numeric segment {segment:?}")]
31    NonNumeric { value: String, segment: String },
32
33    #[error("Erlang major {value} is out of range")]
34    MajorOutOfRange { value: u32 },
35
36    #[error("tag {tag:?} does not start with the project's tag prefix {prefix:?}")]
37    MissingPrefix { tag: String, prefix: String },
38
39    #[error("series {value:?} is not of the form MAJOR.MINOR")]
40    Series { value: String },
41
42    #[error(
43        "Erlang version range {value:?} is not of the form [FROM,BELOW), with FROM below BELOW"
44    )]
45    Range { value: String },
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub struct ReleaseVersion {
50    segments: Vec<u32>,
51}
52
53impl ReleaseVersion {
54    pub fn parse(value: &str) -> Result<Self, ReleaseVersionError> {
55        if value.is_empty() {
56            return Err(ReleaseVersionError::Empty);
57        }
58        let segments = value
59            .split('.')
60            .map(|segment| {
61                segment
62                    .parse::<u32>()
63                    .map_err(|_| ReleaseVersionError::NonNumeric {
64                        value: value.to_owned(),
65                        segment: segment.to_owned(),
66                    })
67            })
68            .collect::<Result<Vec<_>, _>>()?;
69        if !(MIN_SEGMENTS..=MAX_SEGMENTS).contains(&segments.len()) {
70            return Err(ReleaseVersionError::SegmentCount {
71                value: value.to_owned(),
72                count: segments.len(),
73            });
74        }
75        Ok(Self { segments })
76    }
77
78    fn major(&self) -> u32 {
79        self.segments[0]
80    }
81
82    fn minor(&self) -> u32 {
83        self.segments[1]
84    }
85}
86
87impl fmt::Display for ReleaseVersion {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        let mut segments = self.segments.iter();
90        if let Some(first) = segments.next() {
91            write!(f, "{first}")?;
92        }
93        for segment in segments {
94            write!(f, ".{segment}")?;
95        }
96        Ok(())
97    }
98}
99
100impl FromStr for ReleaseVersion {
101    type Err = ReleaseVersionError;
102
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        Self::parse(s)
105    }
106}
107
108fn strip_tag_prefix<'t>(prefix: &str, tag: &'t TagName) -> Result<&'t str, ReleaseVersionError> {
109    tag.as_str()
110        .strip_prefix(prefix)
111        .ok_or_else(|| ReleaseVersionError::MissingPrefix {
112            tag: tag.to_string(),
113            prefix: prefix.to_owned(),
114        })
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
118#[serde(transparent)]
119pub struct ErlangMajor(u16);
120
121impl ErlangMajor {
122    #[must_use]
123    pub fn get(self) -> u16 {
124        self.0
125    }
126}
127
128impl TryFrom<u32> for ErlangMajor {
129    type Error = ReleaseVersionError;
130
131    fn try_from(value: u32) -> Result<Self, Self::Error> {
132        u16::try_from(value)
133            .map(Self)
134            .map_err(|_| ReleaseVersionError::MajorOutOfRange { value })
135    }
136}
137
138impl fmt::Display for ErlangMajor {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(f, "{}", self.0)
141    }
142}
143
144impl FromStr for ErlangMajor {
145    type Err = ReleaseVersionError;
146
147    fn from_str(s: &str) -> Result<Self, Self::Err> {
148        let value = s
149            .parse::<u32>()
150            .map_err(|_| ReleaseVersionError::NonNumeric {
151                value: s.to_owned(),
152                segment: s.to_owned(),
153            })?;
154        Self::try_from(value)
155    }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
159#[serde(try_from = "String", into = "String")]
160pub struct HostVersion(ReleaseVersion);
161
162impl HostVersion {
163    pub fn parse(value: &str) -> Result<Self, ReleaseVersionError> {
164        ReleaseVersion::parse(value).map(Self)
165    }
166
167    #[must_use]
168    pub fn series(&self) -> HostSeries {
169        HostSeries {
170            major: self.0.major(),
171            minor: self.0.minor(),
172        }
173    }
174}
175
176impl fmt::Display for HostVersion {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        self.0.fmt(f)
179    }
180}
181
182impl FromStr for HostVersion {
183    type Err = ReleaseVersionError;
184
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        Self::parse(s)
187    }
188}
189
190impl TryFrom<String> for HostVersion {
191    type Error = ReleaseVersionError;
192
193    fn try_from(value: String) -> Result<Self, Self::Error> {
194        Self::parse(&value)
195    }
196}
197
198impl From<HostVersion> for String {
199    fn from(version: HostVersion) -> Self {
200        version.to_string()
201    }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
205#[serde(try_from = "String", into = "String")]
206pub struct OtpVersion {
207    version: ReleaseVersion,
208    major: ErlangMajor,
209}
210
211impl OtpVersion {
212    pub fn parse(value: &str) -> Result<Self, ReleaseVersionError> {
213        let version = ReleaseVersion::parse(value)?;
214        let major = ErlangMajor::try_from(version.major())?;
215        Ok(Self { version, major })
216    }
217
218    #[must_use]
219    pub fn major(&self) -> ErlangMajor {
220        self.major
221    }
222
223    #[must_use]
224    pub fn line(&self) -> OtpLineVersion {
225        OtpLineVersion {
226            major: self.major,
227            minor: self.version.minor(),
228        }
229    }
230}
231
232impl fmt::Display for OtpVersion {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        self.version.fmt(f)
235    }
236}
237
238impl FromStr for OtpVersion {
239    type Err = ReleaseVersionError;
240
241    fn from_str(s: &str) -> Result<Self, Self::Err> {
242        Self::parse(s)
243    }
244}
245
246impl TryFrom<String> for OtpVersion {
247    type Error = ReleaseVersionError;
248
249    fn try_from(value: String) -> Result<Self, Self::Error> {
250        Self::parse(&value)
251    }
252}
253
254impl From<OtpVersion> for String {
255    fn from(version: OtpVersion) -> Self {
256        version.to_string()
257    }
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
261#[serde(try_from = "String", into = "String")]
262pub struct HostSeries {
263    major: u32,
264    minor: u32,
265}
266
267impl fmt::Display for HostSeries {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        write!(f, "{}.{}", self.major, self.minor)
270    }
271}
272
273fn parse_major_minor(value: &str) -> Option<(u32, u32)> {
274    let (major, minor) = value.split_once('.')?;
275    Some((major.parse().ok()?, minor.parse().ok()?))
276}
277
278impl FromStr for HostSeries {
279    type Err = ReleaseVersionError;
280
281    fn from_str(s: &str) -> Result<Self, Self::Err> {
282        parse_major_minor(s)
283            .map(|(major, minor)| Self { major, minor })
284            .ok_or_else(|| ReleaseVersionError::Series {
285                value: s.to_owned(),
286            })
287    }
288}
289
290impl TryFrom<String> for HostSeries {
291    type Error = ReleaseVersionError;
292
293    fn try_from(value: String) -> Result<Self, Self::Error> {
294        value.parse()
295    }
296}
297
298impl From<HostSeries> for String {
299    fn from(series: HostSeries) -> Self {
300        series.to_string()
301    }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
305#[serde(try_from = "String", into = "String")]
306pub struct OtpLineVersion {
307    major: ErlangMajor,
308    minor: u32,
309}
310
311impl OtpLineVersion {
312    #[must_use]
313    pub fn major(self) -> ErlangMajor {
314        self.major
315    }
316
317    fn as_release(self) -> ReleaseVersion {
318        ReleaseVersion {
319            segments: vec![u32::from(self.major.get()), self.minor],
320        }
321    }
322}
323
324impl fmt::Display for OtpLineVersion {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        write!(f, "{}.{}", self.major, self.minor)
327    }
328}
329
330impl FromStr for OtpLineVersion {
331    type Err = ReleaseVersionError;
332
333    fn from_str(s: &str) -> Result<Self, Self::Err> {
334        let invalid = || ReleaseVersionError::Series {
335            value: s.to_owned(),
336        };
337        let (major, minor) = parse_major_minor(s).ok_or_else(invalid)?;
338        Ok(Self {
339            major: ErlangMajor::try_from(major)?,
340            minor,
341        })
342    }
343}
344
345impl TryFrom<String> for OtpLineVersion {
346    type Error = ReleaseVersionError;
347
348    fn try_from(value: String) -> Result<Self, Self::Error> {
349        value.parse()
350    }
351}
352
353impl From<OtpLineVersion> for String {
354    fn from(line: OtpLineVersion) -> Self {
355        line.to_string()
356    }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
360pub struct HostTag {
361    version: HostVersion,
362    tag: TagName,
363}
364
365impl HostTag {
366    pub fn parse(project: &Project, tag: TagName) -> Result<Self, ReleaseVersionError> {
367        let version = HostVersion::parse(strip_tag_prefix(&project.tag_prefix, &tag)?)?;
368        Ok(Self { version, tag })
369    }
370
371    #[must_use]
372    pub fn tag(&self) -> &TagName {
373        &self.tag
374    }
375
376    #[must_use]
377    pub fn version(&self) -> &HostVersion {
378        &self.version
379    }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
383pub struct OtpTag {
384    version: OtpVersion,
385    tag: TagName,
386}
387
388impl OtpTag {
389    pub fn parse(project: &Project, tag: TagName) -> Result<Self, ReleaseVersionError> {
390        let version = OtpVersion::parse(strip_tag_prefix(&project.tag_prefix, &tag)?)?;
391        Ok(Self { version, tag })
392    }
393
394    #[must_use]
395    pub fn tag(&self) -> &TagName {
396        &self.tag
397    }
398
399    #[must_use]
400    pub fn version(&self) -> &OtpVersion {
401        &self.version
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
406#[serde(try_from = "String", into = "String")]
407pub struct ErlangVersionRange {
408    from: OtpVersion,
409    below: OtpVersion,
410}
411
412impl ErlangVersionRange {
413    #[must_use]
414    pub fn contains(&self, line: OtpLineVersion) -> bool {
415        let line = line.as_release();
416        self.from.version <= line && line < self.below.version
417    }
418}
419
420impl fmt::Display for ErlangVersionRange {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        write!(f, "[{},{})", self.from, self.below)
423    }
424}
425
426impl FromStr for ErlangVersionRange {
427    type Err = ReleaseVersionError;
428
429    fn from_str(s: &str) -> Result<Self, Self::Err> {
430        let invalid = || ReleaseVersionError::Range {
431            value: s.to_owned(),
432        };
433        let (from, below) = s
434            .strip_prefix('[')
435            .and_then(|rest| rest.strip_suffix(')'))
436            .and_then(|inner| inner.split_once(','))
437            .ok_or_else(invalid)?;
438        let from = OtpVersion::parse(from.trim()).map_err(|_| invalid())?;
439        let below = OtpVersion::parse(below.trim()).map_err(|_| invalid())?;
440        if from >= below {
441            return Err(invalid());
442        }
443        Ok(Self { from, below })
444    }
445}
446
447impl TryFrom<String> for ErlangVersionRange {
448    type Error = ReleaseVersionError;
449
450    fn try_from(value: String) -> Result<Self, Self::Error> {
451        value.parse()
452    }
453}
454
455impl From<ErlangVersionRange> for String {
456    fn from(range: ErlangVersionRange) -> Self {
457        range.to_string()
458    }
459}