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 Display for InvalidNameError {
115 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
116 write!(
117 f,
118 "Not a valid package or extra name: \"{}\". Names must start and end with a letter or \
119 digit and may only contain -, _, ., and alphanumeric characters.",
120 self.0
121 )
122 }
123}
124
125impl Error for InvalidNameError {}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct InvalidPipGroupPathError(String);
130
131impl Display for InvalidPipGroupPathError {
132 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
133 write!(
134 f,
135 "The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: {}",
136 self.0,
137 )
138 }
139}
140impl Error for InvalidPipGroupPathError {}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
144pub enum InvalidPipGroupError {
145 Name(InvalidNameError),
146 Path(InvalidPipGroupPathError),
147}
148
149impl Display for InvalidPipGroupError {
150 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
151 match self {
152 Self::Name(e) => e.fmt(f),
153 Self::Path(e) => e.fmt(f),
154 }
155 }
156}
157impl Error for InvalidPipGroupError {}
158impl From<InvalidNameError> for InvalidPipGroupError {
159 fn from(value: InvalidNameError) -> Self {
160 Self::Name(value)
161 }
162}
163impl From<InvalidPipGroupPathError> for InvalidPipGroupError {
164 fn from(value: InvalidPipGroupPathError) -> Self {
165 Self::Path(value)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn normalize() {
175 let inputs = [
176 "friendly-bard",
177 "Friendly-Bard",
178 "FRIENDLY-BARD",
179 "friendly.bard",
180 "friendly_bard",
181 "friendly--bard",
182 "friendly-.bard",
183 "FrIeNdLy-._.-bArD",
184 ];
185 for input in inputs {
186 assert_eq!(
187 validate_and_normalize_ref(input).unwrap().as_ref(),
188 "friendly-bard"
189 );
190 }
191 }
192
193 #[test]
194 fn check() {
195 let inputs = ["friendly-bard", "friendlybard"];
196 for input in inputs {
197 assert!(is_normalized(input).unwrap(), "{input:?}");
198 }
199
200 let inputs = [
201 "friendly.bard",
202 "friendly.BARD",
203 "friendly_bard",
204 "friendly--bard",
205 "friendly-.bard",
206 "FrIeNdLy-._.-bArD",
207 ];
208 for input in inputs {
209 assert!(!is_normalized(input).unwrap(), "{input:?}");
210 }
211 }
212
213 #[test]
214 fn unchanged() {
215 let unchanged = ["friendly-bard", "1okay", "okay2"];
217 for input in unchanged {
218 assert_eq!(validate_and_normalize_ref(input).unwrap().as_ref(), input);
219 assert!(is_normalized(input).unwrap());
220 }
221 }
222
223 #[test]
224 fn failures() {
225 let failures = [
226 "",
227 " starts-with-space",
228 "-starts-with-dash",
229 "ends-with-dash-",
230 "ends-with-space ",
231 "includes!invalid-char",
232 "space in middle",
233 "alpha-α",
234 ];
235 for input in failures {
236 assert!(validate_and_normalize_ref(input).is_err());
237 assert!(is_normalized(input).is_err());
238 }
239 }
240}