Skip to main content

use_astro/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Astro version-family labels.
8#[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    /// Returns the version-family label.
18    #[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/// Astro file-kind labels.
50#[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    /// Returns the file-kind label.
63    #[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/// Astro directory labels.
101#[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    /// Returns the directory label.
114    #[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/// Astro rendering mode labels.
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum AstroRenderingMode {
154    Static,
155    Server,
156    Hybrid,
157}
158
159impl AstroRenderingMode {
160    /// Returns the rendering mode label.
161    #[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/// Common Astro config file labels.
191#[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    /// Returns the config file label.
201    #[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/// Validated Astro integration name metadata.
233#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
234pub struct AstroIntegrationName(String);
235
236impl AstroIntegrationName {
237    /// Creates Astro integration name metadata.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`AstroTextError`] when `input` is empty, contains whitespace, or has unsupported characters.
242    pub fn new(input: &str) -> Result<Self, AstroTextError> {
243        validate_text(input, is_integration_character).map(Self)
244    }
245
246    /// Returns the integration name.
247    #[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/// Validated Astro content collection name metadata.
276#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
277pub struct AstroContentCollectionName(String);
278
279impl AstroContentCollectionName {
280    /// Creates Astro content collection name metadata.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`AstroTextError`] when `input` is empty, contains whitespace, or has unsupported characters.
285    pub fn new(input: &str) -> Result<Self, AstroTextError> {
286        validate_text(input, is_collection_character).map(Self)
287    }
288
289    /// Returns the content collection name.
290    #[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/// Error returned when Astro metadata text is invalid.
319#[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}