Skip to main content

use_api_version/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when API primitive text or labels are invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ApiPrimitiveError {
10    /// The supplied value was empty after trimming.
11    Empty,
12    /// The supplied value used syntax this crate rejects.
13    Invalid,
14    /// The supplied label was not recognized.
15    Unknown,
16}
17
18impl fmt::Display for ApiPrimitiveError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("API primitive value cannot be empty"),
22            Self::Invalid => formatter.write_str("invalid API primitive value"),
23            Self::Unknown => formatter.write_str("unknown API primitive label"),
24        }
25    }
26}
27
28impl Error for ApiPrimitiveError {}
29
30fn validate_api_text(value: &str) -> Result<&str, ApiPrimitiveError> {
31    let trimmed = value.trim();
32    if trimmed.is_empty() {
33        return Err(ApiPrimitiveError::Empty);
34    }
35    if trimmed.chars().any(char::is_control) {
36        return Err(ApiPrimitiveError::Invalid);
37    }
38    Ok(trimmed)
39}
40
41macro_rules! text_newtype {
42    ($name:ident) => {
43        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44        pub struct $name(String);
45
46        impl $name {
47            /// Creates validated text metadata.
48            ///
49            /// # Errors
50            ///
51            /// Returns [ApiPrimitiveError] when the value is empty or contains control characters.
52            pub fn new(value: impl AsRef<str>) -> Result<Self, ApiPrimitiveError> {
53                validate_api_text(value.as_ref()).map(|value| Self(value.to_owned()))
54            }
55
56            /// Parses validated text metadata.
57            ///
58            /// # Errors
59            ///
60            /// Returns [ApiPrimitiveError] when validation fails.
61            pub fn parse(value: impl AsRef<str>) -> Result<Self, ApiPrimitiveError> {
62                Self::new(value)
63            }
64
65            /// Returns the stored text.
66            #[must_use]
67            pub fn as_str(&self) -> &str {
68                &self.0
69            }
70
71            /// Consumes the value and returns the stored text.
72            #[must_use]
73            pub fn into_string(self) -> String {
74                self.0
75            }
76        }
77
78        impl AsRef<str> for $name {
79            fn as_ref(&self) -> &str {
80                self.as_str()
81            }
82        }
83
84        impl fmt::Display for $name {
85            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86                formatter.write_str(self.as_str())
87            }
88        }
89
90        impl FromStr for $name {
91            type Err = ApiPrimitiveError;
92
93            fn from_str(value: &str) -> Result<Self, Self::Err> {
94                Self::new(value)
95            }
96        }
97
98        impl TryFrom<&str> for $name {
99            type Error = ApiPrimitiveError;
100
101            fn try_from(value: &str) -> Result<Self, Self::Error> {
102                Self::new(value)
103            }
104        }
105    };
106}
107
108text_newtype!(ApiVersion);
109text_newtype!(VersionLabel);
110
111/// API version label kind.
112#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113pub enum VersionKind {
114    /// A stable label variant.
115    Simple,
116    /// A stable label variant.
117    Date,
118    /// A stable label variant.
119    Semantic,
120    /// A stable label variant.
121    Custom,
122}
123
124impl VersionKind {
125    /// Returns the stable label.
126    #[must_use]
127    pub const fn as_str(self) -> &'static str {
128        match self {
129            Self::Simple => "simple",
130            Self::Date => "date",
131            Self::Semantic => "semantic",
132            Self::Custom => "custom",
133        }
134    }
135}
136
137impl Default for VersionKind {
138    fn default() -> Self {
139        Self::Simple
140    }
141}
142
143impl fmt::Display for VersionKind {
144    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145        formatter.write_str(self.as_str())
146    }
147}
148
149impl FromStr for VersionKind {
150    type Err = ApiPrimitiveError;
151
152    fn from_str(value: &str) -> Result<Self, Self::Err> {
153        let trimmed = value.trim();
154        if trimmed.is_empty() {
155            return Err(ApiPrimitiveError::Empty);
156        }
157        let normalized = trimmed.to_ascii_lowercase().replace('_', "-");
158        match normalized.as_str() {
159            "simple" => Ok(Self::Simple),
160            "date" => Ok(Self::Date),
161            "semantic" => Ok(Self::Semantic),
162            "custom" => Ok(Self::Custom),
163            _ => Err(ApiPrimitiveError::Unknown),
164        }
165    }
166}
167/// API compatibility labels.
168#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
169pub enum Compatibility {
170    /// A stable label variant.
171    Compatible,
172    /// A stable label variant.
173    Breaking,
174    /// A stable label variant.
175    Deprecated,
176    /// A stable label variant.
177    Unknown,
178}
179
180impl Compatibility {
181    /// Returns the stable label.
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::Compatible => "compatible",
186            Self::Breaking => "breaking",
187            Self::Deprecated => "deprecated",
188            Self::Unknown => "unknown",
189        }
190    }
191}
192
193impl Default for Compatibility {
194    fn default() -> Self {
195        Self::Compatible
196    }
197}
198
199impl fmt::Display for Compatibility {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter.write_str(self.as_str())
202    }
203}
204
205impl FromStr for Compatibility {
206    type Err = ApiPrimitiveError;
207
208    fn from_str(value: &str) -> Result<Self, Self::Err> {
209        let trimmed = value.trim();
210        if trimmed.is_empty() {
211            return Err(ApiPrimitiveError::Empty);
212        }
213        let normalized = trimmed.to_ascii_lowercase().replace('_', "-");
214        match normalized.as_str() {
215            "compatible" => Ok(Self::Compatible),
216            "breaking" => Ok(Self::Breaking),
217            "deprecated" => Ok(Self::Deprecated),
218            "unknown" => Ok(Self::Unknown),
219            _ => Err(ApiPrimitiveError::Unknown),
220        }
221    }
222}
223
224/// Lightweight metadata tying this crate's primary text and label together.
225#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct PrimitiveMetadata {
227    name: ApiVersion,
228    kind: VersionKind,
229}
230
231impl PrimitiveMetadata {
232    /// Creates primitive metadata.
233    #[must_use]
234    pub const fn new(name: ApiVersion, kind: VersionKind) -> Self {
235        Self { name, kind }
236    }
237
238    /// Returns the primary text value.
239    #[must_use]
240    pub const fn name(&self) -> &ApiVersion {
241        &self.name
242    }
243
244    /// Returns the primary label.
245    #[must_use]
246    pub const fn kind(&self) -> VersionKind {
247        self.kind
248    }
249}
250
251impl ApiVersion {
252    /// Returns the broad shape of the version label.
253    #[must_use]
254    pub fn kind(&self) -> VersionKind {
255        let value = self.as_str();
256        if value.starts_with('v')
257            && value[1..]
258                .chars()
259                .all(|character| character.is_ascii_digit())
260        {
261            VersionKind::Simple
262        } else if is_date_like(value) {
263            VersionKind::Date
264        } else if is_semantic_like(value) {
265            VersionKind::Semantic
266        } else {
267            VersionKind::Custom
268        }
269    }
270}
271
272fn is_date_like(value: &str) -> bool {
273    let parts = value.split('-').collect::<Vec<_>>();
274    parts.len() == 3
275        && parts[0].len() == 4
276        && parts[1].len() == 2
277        && parts[2].len() == 2
278        && parts
279            .iter()
280            .all(|part| part.chars().all(|character| character.is_ascii_digit()))
281}
282
283fn is_semantic_like(value: &str) -> bool {
284    let parts = value.split('.').collect::<Vec<_>>();
285    (2..=3).contains(&parts.len())
286        && parts.iter().all(|part| {
287            !part.is_empty() && part.chars().all(|character| character.is_ascii_digit())
288        })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn parses_and_displays_text() -> Result<(), ApiPrimitiveError> {
297        let value = ApiVersion::new("v1")?;
298
299        assert_eq!(value.as_str(), "v1");
300        assert_eq!(value.to_string(), "v1");
301        assert_eq!("v1".parse::<ApiVersion>()?, value);
302        Ok(())
303    }
304
305    #[test]
306    fn rejects_empty_text() {
307        assert_eq!(ApiVersion::new(""), Err(ApiPrimitiveError::Empty));
308    }
309
310    #[test]
311    fn parses_and_displays_labels() -> Result<(), ApiPrimitiveError> {
312        let kind = "simple".parse::<VersionKind>()?;
313
314        assert_eq!(kind, VersionKind::Simple);
315        assert_eq!(kind.to_string(), "simple");
316        Ok(())
317    }
318
319    #[test]
320    fn creates_metadata() -> Result<(), ApiPrimitiveError> {
321        let metadata = PrimitiveMetadata::new(ApiVersion::new("v1")?, VersionKind::default());
322
323        assert_eq!(metadata.name().as_str(), "v1");
324        assert_eq!(metadata.kind(), VersionKind::default());
325        Ok(())
326    }
327}