uv_normalize/
dist_info_name.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::fmt::{Display, Formatter};
4
5/// The normalized name of a `.dist-info` directory.
6///
7/// Like [`PackageName`](crate::PackageName), but without restrictions on the set of allowed
8/// characters, etc.
9///
10/// See: <https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_vendor/packaging/utils.py#L45>
11#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct DistInfoName<'a>(Cow<'a, str>);
13
14impl<'a> DistInfoName<'a> {
15    /// Create a validated, normalized `.dist-info` directory name.
16    pub fn new(name: &'a str) -> Self {
17        if Self::is_normalized(name) {
18            Self(Cow::Borrowed(name))
19        } else {
20            Self(Cow::Owned(Self::normalize(name)))
21        }
22    }
23
24    /// Normalize a `.dist-info` name, converting it to lowercase and collapsing runs
25    /// of `-`, `_`, and `.` down to a single `-`.
26    fn normalize(name: impl AsRef<str>) -> String {
27        let mut normalized = String::with_capacity(name.as_ref().len());
28        let mut last = None;
29        for char in name.as_ref().bytes() {
30            match char {
31                b'A'..=b'Z' => {
32                    normalized.push(char.to_ascii_lowercase() as char);
33                }
34                b'-' | b'_' | b'.' => {
35                    if matches!(last, Some(b'-' | b'_' | b'.')) {
36                        continue;
37                    }
38                    normalized.push('-');
39                }
40                _ => {
41                    normalized.push(char as char);
42                }
43            }
44            last = Some(char);
45        }
46        normalized
47    }
48
49    /// Returns `true` if the name is already normalized.
50    fn is_normalized(name: impl AsRef<str>) -> bool {
51        let mut last = None;
52        for char in name.as_ref().bytes() {
53            match char {
54                b'A'..=b'Z' => {
55                    // Uppercase characters need to be converted to lowercase.
56                    return false;
57                }
58                b'_' | b'.' => {
59                    // `_` and `.` are normalized to `-`.
60                    return false;
61                }
62                b'-' => {
63                    if matches!(last, Some(b'-')) {
64                        // Runs of `-` are normalized to a single `-`.
65                        return false;
66                    }
67                }
68                _ => {}
69            }
70            last = Some(char);
71        }
72        true
73    }
74}
75
76impl Display for DistInfoName<'_> {
77    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
78        self.0.fmt(f)
79    }
80}
81
82impl AsRef<str> for DistInfoName<'_> {
83    fn as_ref(&self) -> &str {
84        &self.0
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn normalize() {
94        let inputs = [
95            "friendly-bard",
96            "Friendly-Bard",
97            "FRIENDLY-BARD",
98            "friendly.bard",
99            "friendly_bard",
100            "friendly--bard",
101            "friendly-.bard",
102            "FrIeNdLy-._.-bArD",
103        ];
104        for input in inputs {
105            assert_eq!(DistInfoName::normalize(input), "friendly-bard");
106        }
107    }
108}