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 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 #[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#[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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
120pub struct PipRequirement(String);
121
122impl PipRequirement {
123 pub fn new(input: &str) -> Result<Self, PipTextError> {
129 let trimmed = non_empty(input)?;
130 Ok(Self(trimmed.to_string()))
131 }
132
133 #[must_use]
135 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138
139 #[must_use]
141 pub fn is_editable(&self) -> bool {
142 is_editable(self.as_str())
143 }
144
145 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub struct PipRequirementFile(String);
177
178impl PipRequirementFile {
179 pub fn new(input: &str) -> Result<Self, PipTextError> {
185 Ok(Self(non_empty(input)?.to_string()))
186 }
187
188 #[must_use]
190 pub fn as_str(&self) -> &str {
191 &self.0
192 }
193
194 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
209pub struct PipIndexUrl(String);
210
211impl PipIndexUrl {
212 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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
243pub struct PipEditableInstall {
244 target: PipInstallTarget,
245}
246
247impl PipEditableInstall {
248 #[must_use]
250 pub const fn new(target: PipInstallTarget) -> Self {
251 Self { target }
252 }
253
254 #[must_use]
256 pub const fn target(&self) -> &PipInstallTarget {
257 &self.target
258 }
259
260 #[must_use]
262 pub const fn is_editable(&self) -> bool {
263 true
264 }
265}
266
267#[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#[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#[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}