Skip to main content

winget_types/
package_identifier.rs

1use core::{fmt, str::FromStr};
2
3use compact_str::CompactString;
4use thiserror::Error;
5
6use super::DISALLOWED_CHARACTERS;
7
8/// The unique identifier for a given package.
9///
10/// This value is generally in the form of `Publisher.Package`. It is
11/// case-sensitive, and must match the folder structure under the partition
12/// directory in GitHub.
13#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "serde", serde(try_from = "CompactString"))]
16#[repr(transparent)]
17pub struct PackageIdentifier(CompactString);
18
19#[derive(Error, Debug, Eq, PartialEq)]
20pub enum PackageIdentifierError {
21    #[error("Package identifier cannot be empty")]
22    Empty,
23    #[error("A part of a package identifier cannot be empty")]
24    EmptyPart,
25    #[error(
26        "Package identifier cannot be more than {} characters long",
27        PackageIdentifier::MAX_CHAR_LENGTH
28    )]
29    TooLong,
30    #[error("Package identifier contains invalid character {_0:?}")]
31    InvalidCharacter(char),
32    #[error(
33        "The length of a part in a package identifier cannot be more than {} characters long",
34        PackageIdentifier::MAX_PART_CHAR_LENGTH
35    )]
36    PartTooLong,
37    #[error(
38        "The number of parts in the package identifier must be between {} and {}",
39        PackageIdentifier::MIN_PARTS,
40        PackageIdentifier::MAX_PARTS
41    )]
42    InvalidPartCount,
43}
44
45impl PackageIdentifier {
46    pub const MAX_CHAR_LENGTH: usize = 128;
47    pub const MIN_PARTS: usize = 2;
48    pub const MAX_PARTS: usize = 8;
49    pub const MAX_PART_CHAR_LENGTH: usize = 32;
50
51    /// Creates a new `PackageIdentifier` from any type that implements `AsRef<str>` and
52    /// `Into<CompactString>`.
53    ///
54    /// # Errors
55    ///
56    /// Will return `Err` if the package identifier:
57    /// 1. Is empty
58    /// 2. Has an empty part
59    /// 3. Is more than 128 characters long
60    /// 4. Has a part more than 32 characters long
61    /// 5. Contains a disallowed character (control, whitespace, or one of [`DISALLOWED_CHARACTERS`])
62    pub fn new<T: AsRef<str> + Into<CompactString>>(
63        identifier: T,
64    ) -> Result<Self, PackageIdentifierError> {
65        let identifier_str = identifier.as_ref();
66
67        if identifier_str.is_empty() {
68            return Err(PackageIdentifierError::Empty);
69        }
70
71        let (char_count, parts_count) = identifier_str.split('.').try_fold(
72            (0, 0),
73            |(total_char_count, part_count), part| {
74                if part.is_empty() {
75                    return Err(PackageIdentifierError::EmptyPart);
76                }
77
78                let part_char_count = part.chars().try_fold(0, |char_count, char| {
79                    if DISALLOWED_CHARACTERS.contains(&char)
80                        || char.is_control()
81                        || char.is_whitespace()
82                    {
83                        return Err(PackageIdentifierError::InvalidCharacter(char));
84                    }
85
86                    Ok(char_count + 1)
87                })?;
88
89                if part_char_count > Self::MAX_PART_CHAR_LENGTH {
90                    return Err(PackageIdentifierError::PartTooLong);
91                }
92
93                Ok((
94                    total_char_count + part_char_count + '.'.len_utf8(),
95                    part_count + 1,
96                ))
97            },
98        )?;
99
100        if char_count > Self::MAX_CHAR_LENGTH {
101            return Err(PackageIdentifierError::TooLong);
102        }
103
104        if !(Self::MIN_PARTS..=Self::MAX_PARTS).contains(&parts_count) {
105            return Err(PackageIdentifierError::InvalidPartCount);
106        }
107
108        Ok(Self(identifier.into()))
109    }
110
111    /// Creates a new `PackageIdentifier` from any type that implements `Into<CompactString>`
112    /// without checking its validity.
113    ///
114    /// # Safety
115    ///
116    /// The package identifier must not:
117    /// 1. Be empty
118    /// 2. Have an empty part
119    /// 3. Be more than 128 characters long
120    /// 4. Have a part more than 32 characters long
121    /// 5. Contain a disallowed character (control, whitespace, or one of [`DISALLOWED_CHARACTERS`])
122    #[must_use]
123    #[inline]
124    pub unsafe fn new_unchecked<T: Into<CompactString>>(identifier: T) -> Self {
125        Self(identifier.into())
126    }
127
128    /// Extracts a string slice containing the entire `PackageIdentifier`.
129    #[must_use]
130    #[inline]
131    pub fn as_str(&self) -> &str {
132        self.0.as_str()
133    }
134}
135
136impl fmt::Display for PackageIdentifier {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        self.0.fmt(f)
139    }
140}
141
142impl FromStr for PackageIdentifier {
143    type Err = PackageIdentifierError;
144
145    fn from_str(s: &str) -> Result<Self, PackageIdentifierError> {
146        Self::new(s)
147    }
148}
149
150impl TryFrom<CompactString> for PackageIdentifier {
151    type Error = PackageIdentifierError;
152
153    #[inline]
154    fn try_from(value: CompactString) -> Result<Self, Self::Error> {
155        Self::new(value)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use alloc::{format, string::String};
162    use core::iter::repeat_n;
163
164    #[cfg(feature = "serde")]
165    use indoc::indoc;
166    use rstest::rstest;
167
168    use crate::{
169        DISALLOWED_CHARACTERS,
170        package_identifier::{PackageIdentifier, PackageIdentifierError},
171    };
172
173    #[rstest]
174    #[case("Package.Identifier")]
175    #[case("Microsoft.PowerShell")]
176    #[case("Google.Chrome.Canary")]
177    #[case("EclipseAdoptium.Temurin.21.JDK")]
178    #[case("A.Long.Package.Identifier.With.Exactly.Eight.Parts")]
179    fn valid_package_identifier(#[case] package_identifier: &str) {
180        assert!(package_identifier.parse::<PackageIdentifier>().is_ok());
181    }
182
183    #[test]
184    fn too_long_package_identifier() {
185        let num_delimiters = PackageIdentifier::MAX_PARTS - 1;
186        let part_length = (PackageIdentifier::MAX_CHAR_LENGTH - num_delimiters)
187            .div_ceil(PackageIdentifier::MAX_PARTS);
188
189        let part = "a".repeat(part_length);
190
191        let identifier =
192            itertools::intersperse(repeat_n(&*part, PackageIdentifier::MAX_PARTS), ".")
193                .collect::<String>();
194
195        assert_eq!(
196            identifier.parse::<PackageIdentifier>(),
197            Err(PackageIdentifierError::TooLong)
198        );
199    }
200
201    #[test]
202    fn too_many_parts_package_identifier() {
203        assert_eq!(
204            itertools::intersperse(repeat_n('a', PackageIdentifier::MAX_PARTS + 1), '.')
205                .collect::<String>()
206                .parse::<PackageIdentifier>(),
207            Err(PackageIdentifierError::InvalidPartCount)
208        );
209
210        assert_eq!(
211            "Really.Long.Package.Identifier.Spanning.More.Than.Eight.Parts"
212                .parse::<PackageIdentifier>(),
213            Err(PackageIdentifierError::InvalidPartCount)
214        );
215    }
216
217    #[test]
218    fn package_identifier_parts_too_long() {
219        let part = "a".repeat(PackageIdentifier::MAX_PART_CHAR_LENGTH + 1);
220
221        let identifier =
222            itertools::intersperse(repeat_n(&*part, PackageIdentifier::MIN_PARTS), ".")
223                .collect::<String>();
224
225        assert_eq!(
226            identifier.parse::<PackageIdentifier>(),
227            Err(PackageIdentifierError::PartTooLong)
228        );
229    }
230
231    #[test]
232    fn too_few_parts_package_identifier() {
233        assert_eq!(
234            "a".repeat(PackageIdentifier::MIN_PARTS - 1)
235                .parse::<PackageIdentifier>(),
236            Err(PackageIdentifierError::InvalidPartCount)
237        );
238
239        assert_eq!(
240            "OnePart".parse::<PackageIdentifier>(),
241            Err(PackageIdentifierError::InvalidPartCount)
242        );
243    }
244
245    #[test]
246    fn whitespace_in_package_identifier() {
247        assert_eq!(
248            "Publisher.Pack age".parse::<PackageIdentifier>(),
249            Err(PackageIdentifierError::InvalidCharacter(' '))
250        );
251    }
252
253    #[test]
254    fn control_chars_in_package_identifier() {
255        for char in '\u{0}'..='\u{1F}' {
256            assert_eq!(
257                format!("Publisher.Pack{char}age").parse::<PackageIdentifier>(),
258                Err(PackageIdentifierError::InvalidCharacter(char))
259            );
260        }
261    }
262
263    #[test]
264    fn package_identifier_disallowed_characters() {
265        for char in DISALLOWED_CHARACTERS {
266            let identifier = format!("Publisher.Pack{char}age");
267
268            assert_eq!(
269                identifier.parse::<PackageIdentifier>(),
270                Err(PackageIdentifierError::InvalidCharacter(char))
271            );
272        }
273    }
274
275    #[test]
276    fn package_identifier_part_empty() {
277        assert!("a.b".parse::<PackageIdentifier>().is_ok());
278        assert_eq!(
279            "a.b.".parse::<PackageIdentifier>(),
280            Err(PackageIdentifierError::EmptyPart)
281        );
282        assert_eq!(
283            "a..b".parse::<PackageIdentifier>(),
284            Err(PackageIdentifierError::EmptyPart)
285        );
286    }
287
288    #[cfg(feature = "serde")]
289    #[derive(serde::Serialize, serde::Deserialize)]
290    #[serde(rename_all = "PascalCase")]
291    struct Manifest {
292        package_identifier: PackageIdentifier,
293    }
294
295    #[cfg(feature = "serde")]
296    #[test]
297    fn serialize_package_identifier() {
298        assert_eq!(
299            serde_yaml::to_string(&Manifest {
300                package_identifier: "Microsoft.PowerShell".parse().unwrap()
301            })
302            .unwrap(),
303            indoc! {"
304                PackageIdentifier: Microsoft.PowerShell
305            "}
306        );
307    }
308
309    #[cfg(feature = "serde")]
310    #[test]
311    fn deserialize_package_identifier() {
312        assert_eq!(
313            serde_yaml::from_str::<Manifest>(indoc! {"
314                PackageIdentifier: Microsoft.PowerShell
315            "})
316            .unwrap()
317            .package_identifier,
318            "Microsoft.PowerShell".parse::<PackageIdentifier>().unwrap()
319        );
320    }
321}