tiny_ver/
lib.rs

1/*
2 * Copyright (c) Peter Bjorklund. All rights reserved. https://github.com/piot/tiny-ver
3 * Licensed under the MIT License. See LICENSE in the project root for license information.
4 */
5
6use std::fmt;
7use std::str::FromStr;
8
9#[derive(Debug, Clone, Eq, PartialEq)]
10pub struct TinyVersion {
11    major: u32,
12    minor: u32,
13    patch: u32,
14    pre_release: Option<String>,
15}
16
17#[derive(Debug, Eq, PartialEq)]
18pub enum ParseError {
19    InvalidFormat,
20    InvalidNumber,
21    InvalidPreRelease,
22}
23
24#[derive(Debug, Eq, PartialEq)]
25pub enum NameError {
26    InvalidName(String),
27}
28
29#[derive(Debug, Eq, PartialEq)]
30pub enum SplitError {
31    MissingHyphen,
32    VersionParseError(ParseError),
33}
34
35impl FromStr for TinyVersion {
36    type Err = ParseError;
37
38    /// Parses a version string in the format "major.minor.patch" or "major.minor.patch-pre_release".
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// # use tiny_ver::TinyVersion;
44    /// let version: TinyVersion = "1.2.3".parse().unwrap();
45    /// assert_eq!(version.to_string(), "1.2.3");
46    ///
47    /// let version: TinyVersion = "1.2.3-beta".parse().unwrap();
48    /// assert_eq!(version.to_string(), "1.2.3-beta");
49    /// ```
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        let mut parts = s.splitn(2, '-');
52        let version_part = parts.next().ok_or(ParseError::InvalidFormat)?;
53        let pre_release_part = parts.next();
54
55        let version_parts: Vec<&str> = version_part.split('.').collect();
56        if version_parts.len() != 3 {
57            return Err(ParseError::InvalidFormat);
58        }
59
60        let major = version_parts[0]
61            .parse()
62            .map_err(|_| ParseError::InvalidNumber)?;
63        let minor = version_parts[1]
64            .parse()
65            .map_err(|_| ParseError::InvalidNumber)?;
66        let patch = version_parts[2]
67            .parse()
68            .map_err(|_| ParseError::InvalidNumber)?;
69
70        let pre_release = match pre_release_part {
71            Some(s) => {
72                // Enforce that the pre-release part is non-empty
73                if s.is_empty() {
74                    return Err(ParseError::InvalidPreRelease);
75                }
76                // Split the pre-release part by '.' to get individual identifiers
77                let identifiers: Vec<&str> = s.split('.').collect();
78                if identifiers.iter().any(|id| id.is_empty()) {
79                    return Err(ParseError::InvalidPreRelease);
80                }
81                for id in identifiers {
82                    // Each identifier must contain only ASCII alphanumeric characters or hyphen.
83                    if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
84                        return Err(ParseError::InvalidPreRelease);
85                    }
86                    // If the identifier is numeric, it must not have leading zeros (except for "0").
87                    if id.chars().all(|c| c.is_ascii_digit()) && id.len() > 1 && id.starts_with('0')
88                    {
89                        return Err(ParseError::InvalidPreRelease);
90                    }
91                }
92                Some(s.to_string())
93            }
94            None => None,
95        };
96
97        Ok(Self {
98            major,
99            minor,
100            patch,
101            pre_release,
102        })
103    }
104}
105
106impl TinyVersion {
107    /// # Errors
108    /// Return `NameError` if the name is not conforming to `is_valid_name`.
109    pub fn versioned_name(&self, name: &str) -> Result<String, NameError> {
110        if !is_valid_name(name) {
111            return Err(NameError::InvalidName(name.to_string()));
112        }
113        let result = self.pre_release.as_ref().map_or_else(
114            || format!("{}-{}.{}.{}", name, self.major, self.minor, self.patch),
115            |pre| {
116                format!(
117                    "{}-{}.{}.{}-{}",
118                    name, self.major, self.minor, self.patch, pre
119                )
120            },
121        );
122
123        Ok(result)
124    }
125}
126
127impl fmt::Display for TinyVersion {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match &self.pre_release {
130            Some(pre) => write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre),
131            None => write!(f, "{}.{}.{}", self.major, self.minor, self.patch),
132        }
133    }
134}
135
136/// Checks if the provided name is valid.
137///
138/// The name must:
139/// - Be non-empty.
140/// - Consist solely of lower-case alphabetic characters, with _' used as an optional separator.
141/// - Not start or end with a '_'.
142#[must_use]
143pub fn is_valid_name(name: &str) -> bool {
144    let Some(first) = name.chars().next() else {
145        return false;
146    };
147
148    let Some(last) = name.chars().last() else {
149        return false;
150    };
151
152    // The first and last characters must be lower-case letters.
153    if !first.is_ascii_lowercase() || !last.is_ascii_lowercase() {
154        return false;
155    }
156    // All characters in between must be lower-case letters or underscores.
157    name.chars().all(|c| c.is_ascii_lowercase() || c == '_')
158}
159
160/// Splits a versioned name into its package name and version.
161///
162/// # Arguments
163///
164/// * `full_name` - A string in the format produced by [`TinyVersion::versioned_name`],
165///   e.g. `"mypackage-1.2.3"` or `"mypackage-1.2.3-beta"`.
166///
167/// # Returns
168///
169/// A tuple containing the package name and the parsed [`TinyVersion`].
170///
171/// # Errors
172///
173/// Returns a [`SplitError`] if:
174/// - The input does not contain a hyphen (thus, no valid separator between package and version).
175/// - The version part cannot be parsed as a valid [`TinyVersion`].
176pub fn split_versioned_name(full_name: &str) -> Result<(String, TinyVersion), SplitError> {
177    let hyphen_index = full_name.find('-').ok_or(SplitError::MissingHyphen)?;
178    let name = &full_name[..hyphen_index];
179    let version_str = &full_name[hyphen_index + 1..];
180    let version = TinyVersion::from_str(version_str).map_err(SplitError::VersionParseError)?;
181
182    Ok((name.to_string(), version))
183}