Skip to main content

use_remix/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Remix version-family labels.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum RemixVersionFamily {
10    Remix1,
11    Remix2,
12}
13
14impl RemixVersionFamily {
15    /// Returns the version-family label.
16    #[must_use]
17    pub const fn as_str(self) -> &'static str {
18        match self {
19            Self::Remix1 => "remix1",
20            Self::Remix2 => "remix2",
21        }
22    }
23}
24
25impl fmt::Display for RemixVersionFamily {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        formatter.write_str(self.as_str())
28    }
29}
30
31impl FromStr for RemixVersionFamily {
32    type Err = RemixNameError;
33
34    fn from_str(input: &str) -> Result<Self, Self::Err> {
35        match normalized_label(input)?.as_str() {
36            "remix1" | "1" => Ok(Self::Remix1),
37            "remix2" | "2" => Ok(Self::Remix2),
38            _ => Err(RemixNameError::UnknownLabel),
39        }
40    }
41}
42
43/// Remix route-kind labels.
44#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub enum RemixRouteKind {
46    PageRoute,
47    ResourceRoute,
48    LayoutRoute,
49    IndexRoute,
50    PathlessLayoutRoute,
51}
52
53impl RemixRouteKind {
54    /// Returns the route-kind label.
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::PageRoute => "page-route",
59            Self::ResourceRoute => "resource-route",
60            Self::LayoutRoute => "layout-route",
61            Self::IndexRoute => "index-route",
62            Self::PathlessLayoutRoute => "pathless-layout-route",
63        }
64    }
65
66    /// Returns whether this kind describes an index route.
67    #[must_use]
68    pub const fn is_index_route(self) -> bool {
69        matches!(self, Self::IndexRoute)
70    }
71
72    /// Returns whether this kind describes a resource route.
73    #[must_use]
74    pub const fn is_resource_route(self) -> bool {
75        matches!(self, Self::ResourceRoute)
76    }
77
78    /// Returns whether this kind describes a layout route.
79    #[must_use]
80    pub const fn is_layout_route(self) -> bool {
81        matches!(self, Self::LayoutRoute | Self::PathlessLayoutRoute)
82    }
83}
84
85impl fmt::Display for RemixRouteKind {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        formatter.write_str(self.as_str())
88    }
89}
90
91impl FromStr for RemixRouteKind {
92    type Err = RemixNameError;
93
94    fn from_str(input: &str) -> Result<Self, Self::Err> {
95        match normalized_label(input)?.as_str() {
96            "pageroute" | "page" => Ok(Self::PageRoute),
97            "resourceroute" | "resource" => Ok(Self::ResourceRoute),
98            "layoutroute" | "layout" => Ok(Self::LayoutRoute),
99            "indexroute" | "index" => Ok(Self::IndexRoute),
100            "pathlesslayoutroute" | "pathlesslayout" => Ok(Self::PathlessLayoutRoute),
101            _ => Err(RemixNameError::UnknownLabel),
102        }
103    }
104}
105
106/// Remix file-kind labels.
107#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub enum RemixFileKind {
109    Route,
110    Root,
111    EntryClient,
112    EntryServer,
113    Config,
114    Styles,
115    ErrorBoundary,
116}
117
118impl RemixFileKind {
119    /// Returns the file-kind label.
120    #[must_use]
121    pub const fn as_str(self) -> &'static str {
122        match self {
123            Self::Route => "route",
124            Self::Root => "root",
125            Self::EntryClient => "entry-client",
126            Self::EntryServer => "entry-server",
127            Self::Config => "config",
128            Self::Styles => "styles",
129            Self::ErrorBoundary => "error-boundary",
130        }
131    }
132}
133
134impl fmt::Display for RemixFileKind {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter.write_str(self.as_str())
137    }
138}
139
140impl FromStr for RemixFileKind {
141    type Err = RemixNameError;
142
143    fn from_str(input: &str) -> Result<Self, Self::Err> {
144        match normalized_label(input)?.as_str() {
145            "route" => Ok(Self::Route),
146            "root" => Ok(Self::Root),
147            "entryclient" => Ok(Self::EntryClient),
148            "entryserver" => Ok(Self::EntryServer),
149            "config" => Ok(Self::Config),
150            "styles" | "style" => Ok(Self::Styles),
151            "errorboundary" => Ok(Self::ErrorBoundary),
152            _ => Err(RemixNameError::UnknownLabel),
153        }
154    }
155}
156
157/// Remix directory labels.
158#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
159pub enum RemixDirectoryKind {
160    App,
161    Routes,
162    Components,
163    Styles,
164    Public,
165    Server,
166}
167
168impl RemixDirectoryKind {
169    /// Returns the directory label.
170    #[must_use]
171    pub const fn as_str(self) -> &'static str {
172        match self {
173            Self::App => "app",
174            Self::Routes => "routes",
175            Self::Components => "components",
176            Self::Styles => "styles",
177            Self::Public => "public",
178            Self::Server => "server",
179        }
180    }
181}
182
183impl fmt::Display for RemixDirectoryKind {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        formatter.write_str(self.as_str())
186    }
187}
188
189impl FromStr for RemixDirectoryKind {
190    type Err = RemixNameError;
191
192    fn from_str(input: &str) -> Result<Self, Self::Err> {
193        match normalized_label(input)?.as_str() {
194            "app" => Ok(Self::App),
195            "routes" => Ok(Self::Routes),
196            "components" => Ok(Self::Components),
197            "styles" | "style" => Ok(Self::Styles),
198            "public" => Ok(Self::Public),
199            "server" => Ok(Self::Server),
200            _ => Err(RemixNameError::UnknownLabel),
201        }
202    }
203}
204
205/// Remix rendering-mode labels.
206#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
207pub enum RemixRenderingMode {
208    Ssr,
209    Spa,
210    Static,
211    Hybrid,
212}
213
214impl RemixRenderingMode {
215    /// Returns the rendering-mode label.
216    #[must_use]
217    pub const fn as_str(self) -> &'static str {
218        match self {
219            Self::Ssr => "ssr",
220            Self::Spa => "spa",
221            Self::Static => "static",
222            Self::Hybrid => "hybrid",
223        }
224    }
225}
226
227impl fmt::Display for RemixRenderingMode {
228    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229        formatter.write_str(self.as_str())
230    }
231}
232
233impl FromStr for RemixRenderingMode {
234    type Err = RemixNameError;
235
236    fn from_str(input: &str) -> Result<Self, Self::Err> {
237        match normalized_label(input)?.as_str() {
238            "ssr" => Ok(Self::Ssr),
239            "spa" => Ok(Self::Spa),
240            "static" => Ok(Self::Static),
241            "hybrid" => Ok(Self::Hybrid),
242            _ => Err(RemixNameError::UnknownLabel),
243        }
244    }
245}
246
247/// Common Remix config file labels.
248#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
249pub enum RemixConfigFile {
250    RemixConfigJs,
251    RemixConfigMjs,
252    ViteConfigTs,
253    ViteConfigJs,
254}
255
256impl RemixConfigFile {
257    /// Returns the config file label.
258    #[must_use]
259    pub const fn as_str(self) -> &'static str {
260        match self {
261            Self::RemixConfigJs => "remix.config.js",
262            Self::RemixConfigMjs => "remix.config.mjs",
263            Self::ViteConfigTs => "vite.config.ts",
264            Self::ViteConfigJs => "vite.config.js",
265        }
266    }
267}
268
269impl fmt::Display for RemixConfigFile {
270    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271        formatter.write_str(self.as_str())
272    }
273}
274
275impl FromStr for RemixConfigFile {
276    type Err = RemixNameError;
277
278    fn from_str(input: &str) -> Result<Self, Self::Err> {
279        match normalized_label(input)?.as_str() {
280            "remixconfigjs" => Ok(Self::RemixConfigJs),
281            "remixconfigmjs" => Ok(Self::RemixConfigMjs),
282            "viteconfigts" => Ok(Self::ViteConfigTs),
283            "viteconfigjs" => Ok(Self::ViteConfigJs),
284            _ => Err(RemixNameError::UnknownLabel),
285        }
286    }
287}
288
289/// Remix data function labels.
290#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
291pub enum RemixDataFunctionKind {
292    Loader,
293    Action,
294    ClientLoader,
295    ClientAction,
296}
297
298impl RemixDataFunctionKind {
299    /// Returns the data function label.
300    #[must_use]
301    pub const fn as_str(self) -> &'static str {
302        match self {
303            Self::Loader => "loader",
304            Self::Action => "action",
305            Self::ClientLoader => "client-loader",
306            Self::ClientAction => "client-action",
307        }
308    }
309}
310
311impl fmt::Display for RemixDataFunctionKind {
312    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
313        formatter.write_str(self.as_str())
314    }
315}
316
317impl FromStr for RemixDataFunctionKind {
318    type Err = RemixNameError;
319
320    fn from_str(input: &str) -> Result<Self, Self::Err> {
321        match normalized_label(input)?.as_str() {
322            "loader" => Ok(Self::Loader),
323            "action" => Ok(Self::Action),
324            "clientloader" => Ok(Self::ClientLoader),
325            "clientaction" => Ok(Self::ClientAction),
326            _ => Err(RemixNameError::UnknownLabel),
327        }
328    }
329}
330
331/// Validated Remix route path metadata.
332#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
333pub struct RemixRoutePath(String);
334
335impl RemixRoutePath {
336    /// Creates Remix route path metadata.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`RemixNameError`] when `input` is empty or contains control characters.
341    pub fn new(input: &str) -> Result<Self, RemixNameError> {
342        validate_non_empty_text(input).map(Self)
343    }
344
345    /// Returns the route path.
346    #[must_use]
347    pub fn as_str(&self) -> &str {
348        &self.0
349    }
350}
351
352impl fmt::Display for RemixRoutePath {
353    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354        formatter.write_str(self.as_str())
355    }
356}
357
358impl FromStr for RemixRoutePath {
359    type Err = RemixNameError;
360
361    fn from_str(input: &str) -> Result<Self, Self::Err> {
362        Self::new(input)
363    }
364}
365
366impl TryFrom<&str> for RemixRoutePath {
367    type Error = RemixNameError;
368
369    fn try_from(value: &str) -> Result<Self, Self::Error> {
370        Self::new(value)
371    }
372}
373
374/// Validated Remix route file name metadata.
375#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
376pub struct RemixRouteFileName(String);
377
378impl RemixRouteFileName {
379    /// Creates Remix route file name metadata.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`RemixNameError`] when `input` is empty, contains whitespace, or has unsupported characters.
384    pub fn new(input: &str) -> Result<Self, RemixNameError> {
385        let trimmed = input.trim();
386        if trimmed.is_empty() {
387            return Err(RemixNameError::Empty);
388        }
389        if trimmed.chars().any(char::is_whitespace) {
390            return Err(RemixNameError::ContainsWhitespace);
391        }
392        if let Some(character) = trimmed
393            .chars()
394            .find(|character| !is_route_file_character(*character))
395        {
396            return Err(RemixNameError::InvalidCharacter { character });
397        }
398        Ok(Self(trimmed.to_string()))
399    }
400
401    /// Returns the route file name.
402    #[must_use]
403    pub fn as_str(&self) -> &str {
404        &self.0
405    }
406}
407
408impl fmt::Display for RemixRouteFileName {
409    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410        formatter.write_str(self.as_str())
411    }
412}
413
414impl FromStr for RemixRouteFileName {
415    type Err = RemixNameError;
416
417    fn from_str(input: &str) -> Result<Self, Self::Err> {
418        Self::new(input)
419    }
420}
421
422impl TryFrom<&str> for RemixRouteFileName {
423    type Error = RemixNameError;
424
425    fn try_from(value: &str) -> Result<Self, Self::Error> {
426        Self::new(value)
427    }
428}
429
430/// Validated Remix resource route name metadata.
431#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
432pub struct RemixResourceRouteName(String);
433
434impl RemixResourceRouteName {
435    /// Creates Remix resource route name metadata.
436    ///
437    /// # Errors
438    ///
439    /// Returns [`RemixNameError`] when `input` is empty or contains control characters.
440    pub fn new(input: &str) -> Result<Self, RemixNameError> {
441        validate_non_empty_text(input).map(Self)
442    }
443
444    /// Returns the resource route name.
445    #[must_use]
446    pub fn as_str(&self) -> &str {
447        &self.0
448    }
449}
450
451impl fmt::Display for RemixResourceRouteName {
452    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
453        formatter.write_str(self.as_str())
454    }
455}
456
457impl FromStr for RemixResourceRouteName {
458    type Err = RemixNameError;
459
460    fn from_str(input: &str) -> Result<Self, Self::Err> {
461        Self::new(input)
462    }
463}
464
465impl TryFrom<&str> for RemixResourceRouteName {
466    type Error = RemixNameError;
467
468    fn try_from(value: &str) -> Result<Self, Self::Error> {
469        Self::new(value)
470    }
471}
472
473/// Error returned when Remix metadata is invalid.
474#[derive(Clone, Copy, Debug, Eq, PartialEq)]
475pub enum RemixNameError {
476    Empty,
477    ContainsWhitespace,
478    InvalidCharacter { character: char },
479    UnknownLabel,
480}
481
482impl fmt::Display for RemixNameError {
483    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
484        match self {
485            Self::Empty => formatter.write_str("Remix metadata text cannot be empty"),
486            Self::ContainsWhitespace => {
487                formatter.write_str("Remix metadata text cannot contain whitespace")
488            }
489            Self::InvalidCharacter { character } => {
490                write!(formatter, "invalid Remix metadata character `{character}`")
491            }
492            Self::UnknownLabel => formatter.write_str("unknown Remix metadata label"),
493        }
494    }
495}
496
497impl Error for RemixNameError {}
498
499fn validate_non_empty_text(input: &str) -> Result<String, RemixNameError> {
500    let trimmed = input.trim();
501    if trimmed.is_empty() {
502        return Err(RemixNameError::Empty);
503    }
504    if let Some(character) = trimmed.chars().find(|character| character.is_control()) {
505        return Err(RemixNameError::InvalidCharacter { character });
506    }
507    Ok(trimmed.to_string())
508}
509
510const fn is_route_file_character(character: char) -> bool {
511    character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | '$')
512}
513
514fn normalized_label(input: &str) -> Result<String, RemixNameError> {
515    let trimmed = input.trim();
516    if trimmed.is_empty() {
517        return Err(RemixNameError::Empty);
518    }
519    Ok(trimmed
520        .chars()
521        .filter(|character| !matches!(character, '-' | '_' | ' ' | '.'))
522        .flat_map(char::to_lowercase)
523        .collect())
524}
525
526#[cfg(test)]
527mod tests {
528    use super::{
529        RemixConfigFile, RemixDataFunctionKind, RemixDirectoryKind, RemixFileKind, RemixNameError,
530        RemixRenderingMode, RemixResourceRouteName, RemixRouteFileName, RemixRouteKind,
531        RemixRoutePath, RemixVersionFamily,
532    };
533
534    #[test]
535    fn validates_route_paths() -> Result<(), RemixNameError> {
536        let route = RemixRoutePath::new("/products/$productId")?;
537        assert_eq!(route.as_str(), "/products/$productId");
538        assert_eq!(RemixRoutePath::new(""), Err(RemixNameError::Empty));
539        assert_eq!(
540            RemixRoutePath::new("/bad\nroute"),
541            Err(RemixNameError::InvalidCharacter { character: '\n' })
542        );
543        Ok(())
544    }
545
546    #[test]
547    fn validates_route_file_names() -> Result<(), RemixNameError> {
548        assert_eq!(
549            RemixRouteFileName::new("products.$id")?.as_str(),
550            "products.$id"
551        );
552        assert_eq!(RemixRouteFileName::new("_index")?.as_str(), "_index");
553        assert_eq!(
554            RemixRouteFileName::new("products id"),
555            Err(RemixNameError::ContainsWhitespace)
556        );
557        assert_eq!(
558            RemixRouteFileName::new("routes/products"),
559            Err(RemixNameError::InvalidCharacter { character: '/' })
560        );
561        Ok(())
562    }
563
564    #[test]
565    fn validates_resource_route_names() -> Result<(), RemixNameError> {
566        let resource = RemixResourceRouteName::new("sitemap.xml")?;
567        assert_eq!(resource.as_str(), "sitemap.xml");
568        assert_eq!(RemixResourceRouteName::new(""), Err(RemixNameError::Empty));
569        Ok(())
570    }
571
572    #[test]
573    fn route_kind_helpers_work() {
574        assert!(RemixRouteKind::IndexRoute.is_index_route());
575        assert!(RemixRouteKind::ResourceRoute.is_resource_route());
576        assert!(RemixRouteKind::LayoutRoute.is_layout_route());
577        assert!(RemixRouteKind::PathlessLayoutRoute.is_layout_route());
578        assert!(!RemixRouteKind::PageRoute.is_resource_route());
579    }
580
581    #[test]
582    fn parses_labels() -> Result<(), RemixNameError> {
583        assert_eq!(
584            "remix2".parse::<RemixVersionFamily>()?,
585            RemixVersionFamily::Remix2
586        );
587        assert_eq!(
588            "resource-route".parse::<RemixRouteKind>()?,
589            RemixRouteKind::ResourceRoute
590        );
591        assert_eq!(
592            "entry-client".parse::<RemixFileKind>()?,
593            RemixFileKind::EntryClient
594        );
595        assert_eq!(
596            "routes".parse::<RemixDirectoryKind>()?,
597            RemixDirectoryKind::Routes
598        );
599        assert_eq!(
600            "ssr".parse::<RemixRenderingMode>()?,
601            RemixRenderingMode::Ssr
602        );
603        assert_eq!(
604            "vite.config.ts".parse::<RemixConfigFile>()?,
605            RemixConfigFile::ViteConfigTs
606        );
607        assert_eq!(
608            "client-loader".parse::<RemixDataFunctionKind>()?,
609            RemixDataFunctionKind::ClientLoader
610        );
611        assert_eq!(RemixRouteKind::PageRoute.to_string(), "page-route");
612        Ok(())
613    }
614}