Skip to main content

use_pip/
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! pip_text_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 pip metadata text.
14            ///
15            /// # Errors
16            ///
17            /// Returns [`PipTextError::Empty`] when `input` is empty after trimming.
18            pub fn new(input: &str) -> Result<Self, PipTextError> {
19                let trimmed = input.trim();
20                if trimmed.is_empty() {
21                    Err(PipTextError::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        impl FromStr for $name {
41            type Err = PipTextError;
42
43            fn from_str(input: &str) -> Result<Self, Self::Err> {
44                Self::new(input)
45            }
46        }
47
48        impl TryFrom<&str> for $name {
49            type Error = PipTextError;
50
51            fn try_from(value: &str) -> Result<Self, Self::Error> {
52                Self::new(value)
53            }
54        }
55    };
56}
57
58/// Common pip command labels.
59#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
60pub enum PipCommand {
61    Install,
62    Uninstall,
63    Freeze,
64    List,
65    Show,
66    Check,
67    Download,
68    Wheel,
69    Config,
70}
71
72impl PipCommand {
73    /// Returns the command label.
74    #[must_use]
75    pub const fn as_str(self) -> &'static str {
76        match self {
77            Self::Install => "install",
78            Self::Uninstall => "uninstall",
79            Self::Freeze => "freeze",
80            Self::List => "list",
81            Self::Show => "show",
82            Self::Check => "check",
83            Self::Download => "download",
84            Self::Wheel => "wheel",
85            Self::Config => "config",
86        }
87    }
88}
89
90impl fmt::Display for PipCommand {
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        formatter.write_str(self.as_str())
93    }
94}
95
96impl FromStr for PipCommand {
97    type Err = PipTextError;
98
99    fn from_str(input: &str) -> Result<Self, Self::Err> {
100        match normalized(input)?.as_str() {
101            "install" => Ok(Self::Install),
102            "uninstall" => Ok(Self::Uninstall),
103            "freeze" => Ok(Self::Freeze),
104            "list" => Ok(Self::List),
105            "show" => Ok(Self::Show),
106            "check" => Ok(Self::Check),
107            "download" => Ok(Self::Download),
108            "wheel" => Ok(Self::Wheel),
109            "config" => Ok(Self::Config),
110            _ => Err(PipTextError::Unknown),
111        }
112    }
113}
114
115pip_text_newtype!(PipPackageSpec);
116pip_text_newtype!(PipInstallTarget);
117
118/// Validated pip requirement text.
119#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
120pub struct PipRequirement(String);
121
122impl PipRequirement {
123    /// Creates pip requirement metadata.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`PipTextError::Empty`] when `input` is empty after trimming.
128    pub fn new(input: &str) -> Result<Self, PipTextError> {
129        let trimmed = non_empty(input)?;
130        Ok(Self(trimmed.to_string()))
131    }
132
133    /// Returns the requirement text.
134    #[must_use]
135    pub fn as_str(&self) -> &str {
136        &self.0
137    }
138
139    /// Returns whether the requirement looks like an editable install option.
140    #[must_use]
141    pub fn is_editable(&self) -> bool {
142        is_editable(self.as_str())
143    }
144
145    /// Returns whether the requirement looks like a requirements-file option.
146    #[must_use]
147    pub fn is_requirements_file(&self) -> bool {
148        is_requirements_file(self.as_str())
149    }
150}
151
152impl fmt::Display for PipRequirement {
153    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154        formatter.write_str(self.as_str())
155    }
156}
157
158impl FromStr for PipRequirement {
159    type Err = PipTextError;
160
161    fn from_str(input: &str) -> Result<Self, Self::Err> {
162        Self::new(input)
163    }
164}
165
166impl TryFrom<&str> for PipRequirement {
167    type Error = PipTextError;
168
169    fn try_from(value: &str) -> Result<Self, Self::Error> {
170        Self::new(value)
171    }
172}
173
174/// pip requirements-file metadata.
175#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub struct PipRequirementFile(String);
177
178impl PipRequirementFile {
179    /// Creates requirements-file metadata.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`PipTextError::Empty`] when `input` is empty after trimming.
184    pub fn new(input: &str) -> Result<Self, PipTextError> {
185        Ok(Self(non_empty(input)?.to_string()))
186    }
187
188    /// Returns the requirements-file path text.
189    #[must_use]
190    pub fn as_str(&self) -> &str {
191        &self.0
192    }
193
194    /// Returns `true` for requirements-file metadata.
195    #[must_use]
196    pub const fn is_requirements_file(&self) -> bool {
197        true
198    }
199}
200
201impl fmt::Display for PipRequirementFile {
202    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203        formatter.write_str(self.as_str())
204    }
205}
206
207/// pip package index URL metadata.
208#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
209pub struct PipIndexUrl(String);
210
211impl PipIndexUrl {
212    /// Creates index URL metadata.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`PipTextError`] when `input` is empty, contains whitespace, or is not HTTP(S)-shaped.
217    pub fn new(input: &str) -> Result<Self, PipTextError> {
218        let trimmed = non_empty(input)?;
219        if trimmed.chars().any(char::is_whitespace) {
220            return Err(PipTextError::ContainsWhitespace);
221        }
222        if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
223            return Err(PipTextError::InvalidUrl);
224        }
225        Ok(Self(trimmed.to_string()))
226    }
227
228    /// Returns the index URL text.
229    #[must_use]
230    pub fn as_str(&self) -> &str {
231        &self.0
232    }
233}
234
235impl fmt::Display for PipIndexUrl {
236    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237        formatter.write_str(self.as_str())
238    }
239}
240
241/// pip editable install metadata.
242#[derive(Clone, Debug, Eq, PartialEq)]
243pub struct PipEditableInstall {
244    target: PipInstallTarget,
245}
246
247impl PipEditableInstall {
248    /// Creates editable install metadata.
249    #[must_use]
250    pub const fn new(target: PipInstallTarget) -> Self {
251        Self { target }
252    }
253
254    /// Returns the editable target.
255    #[must_use]
256    pub const fn target(&self) -> &PipInstallTarget {
257        &self.target
258    }
259
260    /// Returns `true` for editable install metadata.
261    #[must_use]
262    pub const fn is_editable(&self) -> bool {
263        true
264    }
265}
266
267/// Error returned when pip metadata text is invalid.
268#[derive(Clone, Copy, Debug, Eq, PartialEq)]
269pub enum PipTextError {
270    Empty,
271    ContainsWhitespace,
272    InvalidUrl,
273    Unknown,
274}
275
276impl fmt::Display for PipTextError {
277    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278        match self {
279            Self::Empty => formatter.write_str("pip metadata text cannot be empty"),
280            Self::ContainsWhitespace => {
281                formatter.write_str("pip metadata text cannot contain whitespace")
282            }
283            Self::InvalidUrl => {
284                formatter.write_str("pip index URL must start with http:// or https://")
285            }
286            Self::Unknown => formatter.write_str("unknown pip command"),
287        }
288    }
289}
290
291impl Error for PipTextError {}
292
293/// Returns whether `input` looks like an editable install option.
294#[must_use]
295pub fn is_editable(input: &str) -> bool {
296    let trimmed = input.trim();
297    trimmed == "-e" || trimmed.starts_with("-e ") || trimmed.starts_with("--editable ")
298}
299
300/// Returns whether `input` looks like a requirements-file option.
301#[must_use]
302pub fn is_requirements_file(input: &str) -> bool {
303    let trimmed = input.trim();
304    trimmed == "-r" || trimmed.starts_with("-r ") || trimmed.starts_with("--requirement ")
305}
306
307fn normalized(input: &str) -> Result<String, PipTextError> {
308    Ok(non_empty(input)?.to_ascii_lowercase())
309}
310
311fn non_empty(input: &str) -> Result<&str, PipTextError> {
312    let trimmed = input.trim();
313    if trimmed.is_empty() {
314        Err(PipTextError::Empty)
315    } else {
316        Ok(trimmed)
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::{
323        PipCommand, PipEditableInstall, PipIndexUrl, PipInstallTarget, PipRequirement,
324        PipRequirementFile, PipTextError, is_requirements_file,
325    };
326
327    #[test]
328    fn parses_commands_and_requirements() -> Result<(), PipTextError> {
329        let requirement = PipRequirement::new("requests>=2")?;
330
331        assert_eq!("install".parse::<PipCommand>()?, PipCommand::Install);
332        assert_eq!(requirement.as_str(), "requests>=2");
333        assert!(!requirement.is_editable());
334        Ok(())
335    }
336
337    #[test]
338    fn models_requirement_files_and_editable_installs() -> Result<(), PipTextError> {
339        let file = PipRequirementFile::new("requirements.txt")?;
340        let editable = PipEditableInstall::new(PipInstallTarget::new(".")?);
341
342        assert!(file.is_requirements_file());
343        assert!(editable.is_editable());
344        assert!(is_requirements_file("-r requirements-dev.txt"));
345        Ok(())
346    }
347
348    #[test]
349    fn validates_index_urls() -> Result<(), PipTextError> {
350        assert_eq!(
351            PipIndexUrl::new("https://pypi.org/simple")?.as_str(),
352            "https://pypi.org/simple"
353        );
354        assert_eq!(
355            PipIndexUrl::new("ftp://example.test"),
356            Err(PipTextError::InvalidUrl)
357        );
358        Ok(())
359    }
360}