winget_types/
package_identifier.rs1use core::{fmt, str::FromStr};
2
3use compact_str::CompactString;
4use thiserror::Error;
5
6use super::DISALLOWED_CHARACTERS;
7
8#[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 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 #[must_use]
123 #[inline]
124 pub unsafe fn new_unchecked<T: Into<CompactString>>(identifier: T) -> Self {
125 Self(identifier.into())
126 }
127
128 #[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}