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