1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ApiPrimitiveError {
10 Empty,
12 Invalid,
14 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 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 pub fn parse(value: impl AsRef<str>) -> Result<Self, ApiPrimitiveError> {
62 Self::new(value)
63 }
64
65 #[must_use]
67 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70
71 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113pub enum VersionKind {
114 Simple,
116 Date,
118 Semantic,
120 Custom,
122}
123
124impl VersionKind {
125 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
169pub enum Compatibility {
170 Compatible,
172 Breaking,
174 Deprecated,
176 Unknown,
178}
179
180impl Compatibility {
181 #[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#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct PrimitiveMetadata {
227 name: ApiVersion,
228 kind: VersionKind,
229}
230
231impl PrimitiveMetadata {
232 #[must_use]
234 pub const fn new(name: ApiVersion, kind: VersionKind) -> Self {
235 Self { name, kind }
236 }
237
238 #[must_use]
240 pub const fn name(&self) -> &ApiVersion {
241 &self.name
242 }
243
244 #[must_use]
246 pub const fn kind(&self) -> VersionKind {
247 self.kind
248 }
249}
250
251impl ApiVersion {
252 #[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}