1use std::error::Error;
2use std::fmt::{Display, Formatter};
3
4pub use dist_info_name::DistInfoName;
5pub use extra_name::{DefaultExtras, ExtraName};
6pub use group_name::{DEV_DEPENDENCIES, DefaultGroups, GroupName, PipGroupName};
7pub use package_name::PackageName;
8
9use uv_small_str::SmallString;
10
11mod dist_info_name;
12mod extra_name;
13mod group_name;
14mod package_name;
15
16pub(crate) fn validate_and_normalize_ref(
18 name: impl AsRef<str>,
19) -> Result<SmallString, InvalidNameError> {
20 let name = name.as_ref();
21 if is_normalized(name)? {
22 Ok(SmallString::from(name))
23 } else {
24 Ok(SmallString::from(normalize(name)?))
25 }
26}
27
28fn normalize(name: &str) -> Result<String, InvalidNameError> {
30 let mut normalized = String::with_capacity(name.len());
31
32 let mut last = None;
33 for char in name.bytes() {
34 match char {
35 b'A'..=b'Z' => {
36 normalized.push(char.to_ascii_lowercase() as char);
37 }
38 b'a'..=b'z' | b'0'..=b'9' => {
39 normalized.push(char as char);
40 }
41 b'-' | b'_' | b'.' => {
42 match last {
43 None => return Err(InvalidNameError(name.to_string())),
45 Some(b'-' | b'_' | b'.') => {}
46 Some(_) => normalized.push('-'),
47 }
48 }
49 _ => return Err(InvalidNameError(name.to_string())),
50 }
51 last = Some(char);
52 }
53
54 if matches!(last, Some(b'-' | b'_' | b'.')) {
56 return Err(InvalidNameError(name.to_string()));
57 }
58
59 Ok(normalized)
60}
61
62fn is_normalized(name: impl AsRef<str>) -> Result<bool, InvalidNameError> {
64 let mut last = None;
65 for char in name.as_ref().bytes() {
66 match char {
67 b'A'..=b'Z' => {
68 return Ok(false);
70 }
71 b'a'..=b'z' | b'0'..=b'9' => {}
72 b'_' | b'.' => {
73 return Ok(false);
75 }
76 b'-' => {
77 match last {
78 None => return Err(InvalidNameError(name.as_ref().to_string())),
80 Some(b'-') => {
81 return Ok(false);
83 }
84 Some(_) => {}
85 }
86 }
87 _ => return Err(InvalidNameError(name.as_ref().to_string())),
88 }
89 last = Some(char);
90 }
91
92 if matches!(last, Some(b'-' | b'_' | b'.')) {
94 return Err(InvalidNameError(name.as_ref().to_string()));
95 }
96
97 Ok(true)
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct InvalidNameError(String);
103
104impl InvalidNameError {
105 pub fn as_str(&self) -> &str {
107 &self.0
108 }
109}
110
111impl Display for InvalidNameError {
112 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
113 write!(
114 f,
115 "Not a valid package or extra name: \"{}\". Names must start and end with a letter or \
116 digit and may only contain -, _, ., and alphanumeric characters.",
117 self.0
118 )
119 }
120}
121
122impl Error for InvalidNameError {}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct InvalidPipGroupPathError(String);
127
128impl InvalidPipGroupPathError {
129 pub fn as_str(&self) -> &str {
131 &self.0
132 }
133}
134
135impl Display for InvalidPipGroupPathError {
136 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
137 write!(
138 f,
139 "The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: {}",
140 self.0,
141 )
142 }
143}
144impl Error for InvalidPipGroupPathError {}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
148pub enum InvalidPipGroupError {
149 Name(InvalidNameError),
150 Path(InvalidPipGroupPathError),
151}
152
153impl Display for InvalidPipGroupError {
154 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
155 match self {
156 Self::Name(e) => e.fmt(f),
157 Self::Path(e) => e.fmt(f),
158 }
159 }
160}
161impl Error for InvalidPipGroupError {}
162impl From<InvalidNameError> for InvalidPipGroupError {
163 fn from(value: InvalidNameError) -> Self {
164 Self::Name(value)
165 }
166}
167impl From<InvalidPipGroupPathError> for InvalidPipGroupError {
168 fn from(value: InvalidPipGroupPathError) -> Self {
169 Self::Path(value)
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn normalize() {
179 let inputs = [
180 "friendly-bard",
181 "Friendly-Bard",
182 "FRIENDLY-BARD",
183 "friendly.bard",
184 "friendly_bard",
185 "friendly--bard",
186 "friendly-.bard",
187 "FrIeNdLy-._.-bArD",
188 ];
189 for input in inputs {
190 assert_eq!(
191 validate_and_normalize_ref(input).unwrap().as_ref(),
192 "friendly-bard"
193 );
194 }
195 }
196
197 #[test]
198 fn check() {
199 let inputs = ["friendly-bard", "friendlybard"];
200 for input in inputs {
201 assert!(is_normalized(input).unwrap(), "{input:?}");
202 }
203
204 let inputs = [
205 "friendly.bard",
206 "friendly.BARD",
207 "friendly_bard",
208 "friendly--bard",
209 "friendly-.bard",
210 "FrIeNdLy-._.-bArD",
211 ];
212 for input in inputs {
213 assert!(!is_normalized(input).unwrap(), "{input:?}");
214 }
215 }
216
217 #[test]
218 fn unchanged() {
219 let unchanged = ["friendly-bard", "1okay", "okay2"];
221 for input in unchanged {
222 assert_eq!(validate_and_normalize_ref(input).unwrap().as_ref(), input);
223 assert!(is_normalized(input).unwrap());
224 }
225 }
226
227 #[test]
228 fn failures() {
229 let failures = [
230 " starts-with-space",
231 "-starts-with-dash",
232 "ends-with-dash-",
233 "ends-with-space ",
234 "includes!invalid-char",
235 "space in middle",
236 "alpha-α",
237 ];
238 for input in failures {
239 assert!(validate_and_normalize_ref(input).is_err());
240 assert!(is_normalized(input).is_err());
241 }
242 }
243}