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!(RouteTemplate);
109text_newtype!(StaticSegment);
110text_newtype!(DynamicParam);
111text_newtype!(WildcardParam);
112text_newtype!(OptionalSegment);
113
114#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
116pub enum RouteSegmentKind {
117 Static,
119 Dynamic,
121 Wildcard,
123 Optional,
125}
126
127impl RouteSegmentKind {
128 #[must_use]
130 pub const fn as_str(self) -> &'static str {
131 match self {
132 Self::Static => "static",
133 Self::Dynamic => "dynamic",
134 Self::Wildcard => "wildcard",
135 Self::Optional => "optional",
136 }
137 }
138}
139
140impl Default for RouteSegmentKind {
141 fn default() -> Self {
142 Self::Static
143 }
144}
145
146impl fmt::Display for RouteSegmentKind {
147 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148 formatter.write_str(self.as_str())
149 }
150}
151
152impl FromStr for RouteSegmentKind {
153 type Err = ApiPrimitiveError;
154
155 fn from_str(value: &str) -> Result<Self, Self::Err> {
156 let trimmed = value.trim();
157 if trimmed.is_empty() {
158 return Err(ApiPrimitiveError::Empty);
159 }
160 let normalized = trimmed.to_ascii_lowercase().replace('_', "-");
161 match normalized.as_str() {
162 "static" => Ok(Self::Static),
163 "dynamic" => Ok(Self::Dynamic),
164 "wildcard" => Ok(Self::Wildcard),
165 "optional" => Ok(Self::Optional),
166 _ => Err(ApiPrimitiveError::Unknown),
167 }
168 }
169}
170#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub enum RouteMatchKind {
173 Exact,
175 Pattern,
177 Prefix,
179}
180
181impl RouteMatchKind {
182 #[must_use]
184 pub const fn as_str(self) -> &'static str {
185 match self {
186 Self::Exact => "exact",
187 Self::Pattern => "pattern",
188 Self::Prefix => "prefix",
189 }
190 }
191}
192
193impl Default for RouteMatchKind {
194 fn default() -> Self {
195 Self::Exact
196 }
197}
198
199impl fmt::Display for RouteMatchKind {
200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201 formatter.write_str(self.as_str())
202 }
203}
204
205impl FromStr for RouteMatchKind {
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 "exact" => Ok(Self::Exact),
216 "pattern" => Ok(Self::Pattern),
217 "prefix" => Ok(Self::Prefix),
218 _ => Err(ApiPrimitiveError::Unknown),
219 }
220 }
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct PrimitiveMetadata {
226 name: RouteTemplate,
227 kind: RouteSegmentKind,
228}
229
230impl PrimitiveMetadata {
231 #[must_use]
233 pub const fn new(name: RouteTemplate, kind: RouteSegmentKind) -> Self {
234 Self { name, kind }
235 }
236
237 #[must_use]
239 pub const fn name(&self) -> &RouteTemplate {
240 &self.name
241 }
242
243 #[must_use]
245 pub const fn kind(&self) -> RouteSegmentKind {
246 self.kind
247 }
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct RouteSegment {
253 value: String,
254 kind: RouteSegmentKind,
255}
256
257impl RouteSegment {
258 #[must_use]
260 pub fn classify(value: impl AsRef<str>) -> Self {
261 let value = value.as_ref().trim().to_owned();
262 let kind = if value.starts_with(':') {
263 RouteSegmentKind::Dynamic
264 } else if value.starts_with('*') {
265 RouteSegmentKind::Wildcard
266 } else if value.starts_with('[') && value.ends_with(']') {
267 RouteSegmentKind::Optional
268 } else {
269 RouteSegmentKind::Static
270 };
271 Self { value, kind }
272 }
273
274 #[must_use]
276 pub fn as_str(&self) -> &str {
277 &self.value
278 }
279
280 #[must_use]
282 pub const fn kind(&self) -> RouteSegmentKind {
283 self.kind
284 }
285}
286
287impl RouteTemplate {
288 #[must_use]
290 pub fn segments(&self) -> Vec<RouteSegment> {
291 self.as_str()
292 .split('/')
293 .filter(|segment| !segment.is_empty())
294 .map(RouteSegment::classify)
295 .collect()
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn parses_and_displays_text() -> Result<(), ApiPrimitiveError> {
305 let value = RouteTemplate::new("/users/:id")?;
306
307 assert_eq!(value.as_str(), "/users/:id");
308 assert_eq!(value.to_string(), "/users/:id");
309 assert_eq!("/users/:id".parse::<RouteTemplate>()?, value);
310 Ok(())
311 }
312
313 #[test]
314 fn rejects_empty_text() {
315 assert_eq!(RouteTemplate::new(""), Err(ApiPrimitiveError::Empty));
316 }
317
318 #[test]
319 fn parses_and_displays_labels() -> Result<(), ApiPrimitiveError> {
320 let kind = "static".parse::<RouteSegmentKind>()?;
321
322 assert_eq!(kind, RouteSegmentKind::Static);
323 assert_eq!(kind.to_string(), "static");
324 Ok(())
325 }
326
327 #[test]
328 fn creates_metadata() -> Result<(), ApiPrimitiveError> {
329 let metadata = PrimitiveMetadata::new(
330 RouteTemplate::new("/users/:id")?,
331 RouteSegmentKind::default(),
332 );
333
334 assert_eq!(metadata.name().as_str(), "/users/:id");
335 assert_eq!(metadata.kind(), RouteSegmentKind::default());
336 Ok(())
337 }
338}