Skip to main content

use_npm/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7macro_rules! string_newtype {
8    ($name:ident) => {
9        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10        pub struct $name(String);
11
12        impl $name {
13            /// Creates non-empty npm text metadata.
14            ///
15            /// # Errors
16            ///
17            /// Returns [`NpmTextError::Empty`] when `input` is empty after trimming.
18            pub fn new(input: &str) -> Result<Self, NpmTextError> {
19                let trimmed = input.trim();
20                if trimmed.is_empty() {
21                    Err(NpmTextError::Empty)
22                } else {
23                    Ok(Self(trimmed.to_string()))
24                }
25            }
26
27            /// Returns the stored text.
28            #[must_use]
29            pub fn as_str(&self) -> &str {
30                &self.0
31            }
32        }
33
34        impl fmt::Display for $name {
35            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36                formatter.write_str(self.as_str())
37            }
38        }
39    };
40}
41
42/// Common npm command labels.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub enum NpmCommand {
45    Install,
46    Ci,
47    Run,
48    Test,
49    Publish,
50    Pack,
51    Version,
52    Audit,
53}
54
55impl NpmCommand {
56    /// Returns the command label.
57    #[must_use]
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::Install => "install",
61            Self::Ci => "ci",
62            Self::Run => "run",
63            Self::Test => "test",
64            Self::Publish => "publish",
65            Self::Pack => "pack",
66            Self::Version => "version",
67            Self::Audit => "audit",
68        }
69    }
70}
71
72impl FromStr for NpmCommand {
73    type Err = NpmCommandParseError;
74
75    fn from_str(input: &str) -> Result<Self, Self::Err> {
76        parse_command(input, |label| match label {
77            "install" | "i" => Some(Self::Install),
78            "ci" => Some(Self::Ci),
79            "run" => Some(Self::Run),
80            "test" | "t" => Some(Self::Test),
81            "publish" => Some(Self::Publish),
82            "pack" => Some(Self::Pack),
83            "version" => Some(Self::Version),
84            "audit" => Some(Self::Audit),
85            _ => None,
86        })
87    }
88}
89
90/// Error returned while parsing npm commands.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum NpmCommandParseError {
93    Empty,
94    Unknown,
95}
96
97impl fmt::Display for NpmCommandParseError {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::Empty => formatter.write_str("npm command cannot be empty"),
101            Self::Unknown => formatter.write_str("unknown npm command"),
102        }
103    }
104}
105
106impl Error for NpmCommandParseError {}
107
108string_newtype!(NpmScriptCommand);
109string_newtype!(NpmPackageSpec);
110
111/// npm registry URL metadata.
112#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113pub struct NpmRegistryUrl(String);
114
115impl NpmRegistryUrl {
116    /// Creates registry URL metadata.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`NpmTextError`] when `input` is empty, contains whitespace, or is not HTTP(S)-shaped.
121    pub fn new(input: &str) -> Result<Self, NpmTextError> {
122        let trimmed = input.trim();
123        if trimmed.is_empty() {
124            return Err(NpmTextError::Empty);
125        }
126        if trimmed.chars().any(char::is_whitespace) {
127            return Err(NpmTextError::ContainsWhitespace);
128        }
129        if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
130            return Err(NpmTextError::InvalidUrl);
131        }
132        Ok(Self(trimmed.to_string()))
133    }
134
135    /// Returns the registry URL label.
136    #[must_use]
137    pub fn as_str(&self) -> &str {
138        &self.0
139    }
140}
141
142/// Error returned when npm text metadata is invalid.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum NpmTextError {
145    Empty,
146    ContainsWhitespace,
147    InvalidUrl,
148}
149
150impl fmt::Display for NpmTextError {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            Self::Empty => formatter.write_str("npm metadata text cannot be empty"),
154            Self::ContainsWhitespace => {
155                formatter.write_str("npm metadata text cannot contain whitespace")
156            }
157            Self::InvalidUrl => {
158                formatter.write_str("npm registry URL must start with http:// or https://")
159            }
160        }
161    }
162}
163
164impl Error for NpmTextError {}
165
166fn parse_command<F>(input: &str, parse: F) -> Result<NpmCommand, NpmCommandParseError>
167where
168    F: FnOnce(&str) -> Option<NpmCommand>,
169{
170    let trimmed = input.trim();
171    if trimmed.is_empty() {
172        return Err(NpmCommandParseError::Empty);
173    }
174    parse(trimmed.to_ascii_lowercase().as_str()).ok_or(NpmCommandParseError::Unknown)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::{NpmCommand, NpmPackageSpec, NpmRegistryUrl, NpmTextError};
180
181    #[test]
182    fn models_npm_metadata() -> Result<(), Box<dyn std::error::Error>> {
183        assert_eq!("ci".parse::<NpmCommand>()?, NpmCommand::Ci);
184        assert_eq!(
185            NpmRegistryUrl::new("https://registry.npmjs.org/")?.as_str(),
186            "https://registry.npmjs.org/"
187        );
188        assert_eq!(
189            NpmPackageSpec::new("react@latest")?.as_str(),
190            "react@latest"
191        );
192        assert_eq!(
193            NpmRegistryUrl::new("registry.npmjs.org"),
194            Err(NpmTextError::InvalidUrl)
195        );
196        Ok(())
197    }
198}