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, Hash, Ord, PartialEq, PartialOrd)]
9pub enum AstroVersionFamily {
10 Astro2,
11 Astro3,
12 Astro4,
13 Astro5,
14}
15
16impl AstroVersionFamily {
17 #[must_use]
19 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::Astro2 => "astro2",
22 Self::Astro3 => "astro3",
23 Self::Astro4 => "astro4",
24 Self::Astro5 => "astro5",
25 }
26 }
27}
28
29impl fmt::Display for AstroVersionFamily {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter.write_str(self.as_str())
32 }
33}
34
35impl FromStr for AstroVersionFamily {
36 type Err = AstroTextError;
37
38 fn from_str(input: &str) -> Result<Self, Self::Err> {
39 match normalized_label(input)?.as_str() {
40 "astro2" | "2" => Ok(Self::Astro2),
41 "astro3" | "3" => Ok(Self::Astro3),
42 "astro4" | "4" => Ok(Self::Astro4),
43 "astro5" | "5" => Ok(Self::Astro5),
44 _ => Err(AstroTextError::UnknownLabel),
45 }
46 }
47}
48
49#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub enum AstroFileKind {
52 Page,
53 Layout,
54 Component,
55 Content,
56 Endpoint,
57 Middleware,
58 Config,
59}
60
61impl AstroFileKind {
62 #[must_use]
64 pub const fn as_str(self) -> &'static str {
65 match self {
66 Self::Page => "page",
67 Self::Layout => "layout",
68 Self::Component => "component",
69 Self::Content => "content",
70 Self::Endpoint => "endpoint",
71 Self::Middleware => "middleware",
72 Self::Config => "config",
73 }
74 }
75}
76
77impl fmt::Display for AstroFileKind {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 formatter.write_str(self.as_str())
80 }
81}
82
83impl FromStr for AstroFileKind {
84 type Err = AstroTextError;
85
86 fn from_str(input: &str) -> Result<Self, Self::Err> {
87 match normalized_label(input)?.as_str() {
88 "page" => Ok(Self::Page),
89 "layout" => Ok(Self::Layout),
90 "component" => Ok(Self::Component),
91 "content" => Ok(Self::Content),
92 "endpoint" => Ok(Self::Endpoint),
93 "middleware" => Ok(Self::Middleware),
94 "config" => Ok(Self::Config),
95 _ => Err(AstroTextError::UnknownLabel),
96 }
97 }
98}
99
100#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub enum AstroDirectoryKind {
103 Pages,
104 Layouts,
105 Components,
106 Content,
107 Public,
108 Src,
109 Integrations,
110}
111
112impl AstroDirectoryKind {
113 #[must_use]
115 pub const fn as_str(self) -> &'static str {
116 match self {
117 Self::Pages => "pages",
118 Self::Layouts => "layouts",
119 Self::Components => "components",
120 Self::Content => "content",
121 Self::Public => "public",
122 Self::Src => "src",
123 Self::Integrations => "integrations",
124 }
125 }
126}
127
128impl fmt::Display for AstroDirectoryKind {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 formatter.write_str(self.as_str())
131 }
132}
133
134impl FromStr for AstroDirectoryKind {
135 type Err = AstroTextError;
136
137 fn from_str(input: &str) -> Result<Self, Self::Err> {
138 match normalized_label(input)?.as_str() {
139 "pages" => Ok(Self::Pages),
140 "layouts" => Ok(Self::Layouts),
141 "components" => Ok(Self::Components),
142 "content" => Ok(Self::Content),
143 "public" => Ok(Self::Public),
144 "src" => Ok(Self::Src),
145 "integrations" => Ok(Self::Integrations),
146 _ => Err(AstroTextError::UnknownLabel),
147 }
148 }
149}
150
151#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum AstroRenderingMode {
154 Static,
155 Server,
156 Hybrid,
157}
158
159impl AstroRenderingMode {
160 #[must_use]
162 pub const fn as_str(self) -> &'static str {
163 match self {
164 Self::Static => "static",
165 Self::Server => "server",
166 Self::Hybrid => "hybrid",
167 }
168 }
169}
170
171impl fmt::Display for AstroRenderingMode {
172 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
173 formatter.write_str(self.as_str())
174 }
175}
176
177impl FromStr for AstroRenderingMode {
178 type Err = AstroTextError;
179
180 fn from_str(input: &str) -> Result<Self, Self::Err> {
181 match normalized_label(input)?.as_str() {
182 "static" => Ok(Self::Static),
183 "server" | "ssr" => Ok(Self::Server),
184 "hybrid" => Ok(Self::Hybrid),
185 _ => Err(AstroTextError::UnknownLabel),
186 }
187 }
188}
189
190#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
192pub enum AstroConfigFile {
193 AstroConfigJs,
194 AstroConfigMjs,
195 AstroConfigTs,
196 AstroConfigMts,
197}
198
199impl AstroConfigFile {
200 #[must_use]
202 pub const fn as_str(self) -> &'static str {
203 match self {
204 Self::AstroConfigJs => "astro.config.js",
205 Self::AstroConfigMjs => "astro.config.mjs",
206 Self::AstroConfigTs => "astro.config.ts",
207 Self::AstroConfigMts => "astro.config.mts",
208 }
209 }
210}
211
212impl fmt::Display for AstroConfigFile {
213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214 formatter.write_str(self.as_str())
215 }
216}
217
218impl FromStr for AstroConfigFile {
219 type Err = AstroTextError;
220
221 fn from_str(input: &str) -> Result<Self, Self::Err> {
222 match normalized_label(input)?.as_str() {
223 "astroconfigjs" | "astro.config.js" => Ok(Self::AstroConfigJs),
224 "astroconfigmjs" | "astro.config.mjs" => Ok(Self::AstroConfigMjs),
225 "astroconfigts" | "astro.config.ts" => Ok(Self::AstroConfigTs),
226 "astroconfigmts" | "astro.config.mts" => Ok(Self::AstroConfigMts),
227 _ => Err(AstroTextError::UnknownLabel),
228 }
229 }
230}
231
232#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
234pub struct AstroIntegrationName(String);
235
236impl AstroIntegrationName {
237 pub fn new(input: &str) -> Result<Self, AstroTextError> {
243 validate_text(input, is_integration_character).map(Self)
244 }
245
246 #[must_use]
248 pub fn as_str(&self) -> &str {
249 &self.0
250 }
251}
252
253impl fmt::Display for AstroIntegrationName {
254 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255 formatter.write_str(self.as_str())
256 }
257}
258
259impl FromStr for AstroIntegrationName {
260 type Err = AstroTextError;
261
262 fn from_str(input: &str) -> Result<Self, Self::Err> {
263 Self::new(input)
264 }
265}
266
267impl TryFrom<&str> for AstroIntegrationName {
268 type Error = AstroTextError;
269
270 fn try_from(value: &str) -> Result<Self, Self::Error> {
271 Self::new(value)
272 }
273}
274
275#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
277pub struct AstroContentCollectionName(String);
278
279impl AstroContentCollectionName {
280 pub fn new(input: &str) -> Result<Self, AstroTextError> {
286 validate_text(input, is_collection_character).map(Self)
287 }
288
289 #[must_use]
291 pub fn as_str(&self) -> &str {
292 &self.0
293 }
294}
295
296impl fmt::Display for AstroContentCollectionName {
297 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298 formatter.write_str(self.as_str())
299 }
300}
301
302impl FromStr for AstroContentCollectionName {
303 type Err = AstroTextError;
304
305 fn from_str(input: &str) -> Result<Self, Self::Err> {
306 Self::new(input)
307 }
308}
309
310impl TryFrom<&str> for AstroContentCollectionName {
311 type Error = AstroTextError;
312
313 fn try_from(value: &str) -> Result<Self, Self::Error> {
314 Self::new(value)
315 }
316}
317
318#[derive(Clone, Copy, Debug, Eq, PartialEq)]
320pub enum AstroTextError {
321 Empty,
322 ContainsWhitespace,
323 InvalidCharacter { character: char },
324 UnknownLabel,
325}
326
327impl fmt::Display for AstroTextError {
328 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
329 match self {
330 Self::Empty => formatter.write_str("Astro metadata text cannot be empty"),
331 Self::ContainsWhitespace => {
332 formatter.write_str("Astro metadata text cannot contain whitespace")
333 }
334 Self::InvalidCharacter { character } => {
335 write!(formatter, "invalid Astro metadata character `{character}`")
336 }
337 Self::UnknownLabel => formatter.write_str("unknown Astro metadata label"),
338 }
339 }
340}
341
342impl Error for AstroTextError {}
343
344fn validate_text(input: &str, is_allowed: fn(char) -> bool) -> Result<String, AstroTextError> {
345 let trimmed = input.trim();
346 if trimmed.is_empty() {
347 return Err(AstroTextError::Empty);
348 }
349 if trimmed.chars().any(char::is_whitespace) {
350 return Err(AstroTextError::ContainsWhitespace);
351 }
352 if let Some(character) = trimmed.chars().find(|character| !is_allowed(*character)) {
353 return Err(AstroTextError::InvalidCharacter { character });
354 }
355 Ok(trimmed.to_string())
356}
357
358const fn is_integration_character(character: char) -> bool {
359 character.is_ascii_alphanumeric() || matches!(character, '@' | '/' | '.' | '_' | '-')
360}
361
362const fn is_collection_character(character: char) -> bool {
363 character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
364}
365
366fn normalized_label(input: &str) -> Result<String, AstroTextError> {
367 let trimmed = input.trim();
368 if trimmed.is_empty() {
369 return Err(AstroTextError::Empty);
370 }
371 Ok(trimmed
372 .chars()
373 .filter(|character| !matches!(character, '-' | '_' | ' '))
374 .flat_map(char::to_lowercase)
375 .collect())
376}
377
378#[cfg(test)]
379mod tests {
380 use super::{
381 AstroConfigFile, AstroContentCollectionName, AstroDirectoryKind, AstroFileKind,
382 AstroIntegrationName, AstroRenderingMode, AstroTextError, AstroVersionFamily,
383 };
384
385 #[test]
386 fn validates_integration_names() -> Result<(), AstroTextError> {
387 let integration = AstroIntegrationName::new("@astrojs/mdx")?;
388 assert_eq!(integration.as_str(), "@astrojs/mdx");
389 assert_eq!(AstroIntegrationName::new(""), Err(AstroTextError::Empty));
390 assert_eq!(
391 AstroIntegrationName::new("astro mdx"),
392 Err(AstroTextError::ContainsWhitespace)
393 );
394 assert_eq!(
395 AstroIntegrationName::new("astro💫"),
396 Err(AstroTextError::InvalidCharacter { character: '💫' })
397 );
398 Ok(())
399 }
400
401 #[test]
402 fn validates_collection_names() -> Result<(), AstroTextError> {
403 let collection = AstroContentCollectionName::new("blog_posts")?;
404 assert_eq!(collection.as_str(), "blog_posts");
405 assert_eq!(
406 AstroContentCollectionName::new("blog/posts"),
407 Err(AstroTextError::InvalidCharacter { character: '/' })
408 );
409 Ok(())
410 }
411
412 #[test]
413 fn parses_labels() -> Result<(), AstroTextError> {
414 assert_eq!(
415 "astro5".parse::<AstroVersionFamily>()?,
416 AstroVersionFamily::Astro5
417 );
418 assert_eq!("page".parse::<AstroFileKind>()?, AstroFileKind::Page);
419 assert_eq!(
420 "src".parse::<AstroDirectoryKind>()?,
421 AstroDirectoryKind::Src
422 );
423 assert_eq!(
424 "server".parse::<AstroRenderingMode>()?,
425 AstroRenderingMode::Server
426 );
427 assert_eq!(
428 "astro.config.ts".parse::<AstroConfigFile>()?,
429 AstroConfigFile::AstroConfigTs
430 );
431 assert_eq!(AstroRenderingMode::Hybrid.to_string(), "hybrid");
432 Ok(())
433 }
434}