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