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, 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
29impl FromStr for TinyVersion {
30    type Err = ParseError;
31
32    /// Parses a version string in the format "major.minor.patch" or "major.minor.patch-pre_release".
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// # use tiny_ver::TinyVersion;
38    /// let version: TinyVersion = "1.2.3".parse().unwrap();
39    /// assert_eq!(version.to_string(), "1.2.3");
40    ///
41    /// let version: TinyVersion = "1.2.3-beta".parse().unwrap();
42    /// assert_eq!(version.to_string(), "1.2.3-beta");
43    /// ```
44    fn from_str(s: &str) -> Result<Self, Self::Err> {
45        let mut parts = s.splitn(2, '-');
46        let version_part = parts.next().ok_or(ParseError::InvalidFormat)?;
47        let pre_release_part = parts.next();
48
49        let version_parts: Vec<&str> = version_part.split('.').collect();
50        if version_parts.len() != 3 {
51            return Err(ParseError::InvalidFormat);
52        }
53
54        let major = version_parts[0]
55            .parse()
56            .map_err(|_| ParseError::InvalidNumber)?;
57        let minor = version_parts[1]
58            .parse()
59            .map_err(|_| ParseError::InvalidNumber)?;
60        let patch = version_parts[2]
61            .parse()
62            .map_err(|_| ParseError::InvalidNumber)?;
63
64        let pre_release = match pre_release_part {
65            Some(s) => {
66                // Enforce that the pre-release part is non-empty
67                if s.is_empty() {
68                    return Err(ParseError::InvalidPreRelease);
69                }
70                // Split the pre-release part by '.' to get individual identifiers
71                let identifiers: Vec<&str> = s.split('.').collect();
72                if identifiers.iter().any(|id| id.is_empty()) {
73                    return Err(ParseError::InvalidPreRelease);
74                }
75                for id in identifiers {
76                    // Each identifier must contain only ASCII alphanumeric characters or hyphen.
77                    if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
78                        return Err(ParseError::InvalidPreRelease);
79                    }
80                    // If the identifier is numeric, it must not have leading zeros (except for "0").
81                    if id.chars().all(|c| c.is_ascii_digit()) && id.len() > 1 && id.starts_with('0')
82                    {
83                        return Err(ParseError::InvalidPreRelease);
84                    }
85                }
86                Some(s.to_string())
87            }
88            None => None,
89        };
90
91        Ok(Self {
92            major,
93            minor,
94            patch,
95            pre_release,
96        })
97    }
98}
99
100impl TinyVersion {
101    /// # Errors
102    /// Return `NameError` if the name is not conforming to `is_valid_name`.
103    pub fn versioned_name(&self, name: &str) -> Result<String, NameError> {
104        if !is_valid_name(name) {
105            return Err(NameError::InvalidName(name.to_string()));
106        }
107        let result = self.pre_release.as_ref().map_or_else(
108            || format!("{}-{}.{}.{}", name, self.major, self.minor, self.patch),
109            |pre| {
110                format!(
111                    "{}-{}.{}.{}-{}",
112                    name, self.major, self.minor, self.patch, pre
113                )
114            },
115        );
116
117        Ok(result)
118    }
119}
120
121impl fmt::Display for TinyVersion {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match &self.pre_release {
124            Some(pre) => write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre),
125            None => write!(f, "{}.{}.{}", self.major, self.minor, self.patch),
126        }
127    }
128}
129
130/// Checks if the provided name is valid.
131///
132/// The name must:
133/// - Be non-empty.
134/// - Consist solely of lower-case alphabetic characters, with _' used as an optional separator.
135/// - Not start or end with a '_'.
136#[must_use]
137pub fn is_valid_name(name: &str) -> bool {
138    let Some(first) = name.chars().next() else {
139        return false;
140    };
141
142    let Some(last) = name.chars().last() else {
143        return false;
144    };
145
146    // The first and last characters must be lower-case letters.
147    if !first.is_ascii_lowercase() || !last.is_ascii_lowercase() {
148        return false;
149    }
150    // All characters in between must be lower-case letters or underscores.
151    name.chars().all(|c| c.is_ascii_lowercase() || c == '_')
152}