1use crate::SchemaError;
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct IndexUrl(url::Url);
17
18impl IndexUrl {
19 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
20 let url = parse_registry_scheme_url(raw, |url, scheme| {
21 SchemaError::UnsupportedIndexUrlScheme { url, scheme }
22 })?;
23 Ok(Self(url))
24 }
25
26 pub fn as_url(&self) -> &url::Url {
27 &self.0
28 }
29}
30
31impl fmt::Display for IndexUrl {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 self.0.fmt(f)
34 }
35}
36
37impl<'de> serde::Deserialize<'de> for IndexUrl {
38 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39 where
40 D: serde::Deserializer<'de>,
41 {
42 let raw = String::deserialize(deserializer)?;
43 Self::try_new(&raw).map_err(serde::de::Error::custom)
44 }
45}
46
47impl serde::Serialize for IndexUrl {
48 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
49 where
50 S: serde::Serializer,
51 {
52 serializer.collect_str(&self.0)
53 }
54}
55
56pub(crate) fn parse_registry_scheme_url(
62 raw: &str,
63 scheme_error: impl FnOnce(String, String) -> SchemaError,
64) -> Result<url::Url, SchemaError> {
65 let url = url::Url::parse(raw).map_err(|source| SchemaError::InvalidUrl {
66 url: raw.to_owned(),
67 source,
68 })?;
69 match url.scheme() {
70 "https" | "http" | "file" => Ok(url),
71 other => Err(scheme_error(raw.to_owned(), other.to_owned())),
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81pub struct PluginName(String);
82
83impl PluginName {
84 const WINDOWS_RESERVED: &'static [&'static str] = &[
89 "con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", "com5", "com6", "com7",
90 "com8", "com9", "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8",
91 "lpt9",
92 ];
93
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97
98 pub fn canonical(&self) -> String {
107 canonical_name(&self.0)
108 }
109
110 pub fn into_inner(self) -> String {
111 self.0
112 }
113
114 fn validate(name: &str) -> Result<(), SchemaError> {
115 let bytes = name.as_bytes();
116 if bytes.is_empty() || bytes.len() > 64 {
117 return Err(SchemaError::InvalidPluginName {
118 name: name.to_owned(),
119 });
120 }
121 let first = bytes[0];
122 let is_alpha = |b: u8| b.is_ascii_uppercase() || b.is_ascii_lowercase();
123 let is_alnum = |b: u8| b.is_ascii_digit() || is_alpha(b);
124 if !is_alpha(first) {
125 return Err(SchemaError::InvalidPluginName {
126 name: name.to_owned(),
127 });
128 }
129 for &b in &bytes[1..] {
130 if !(is_alnum(b) || b == b'-' || b == b'_') {
131 return Err(SchemaError::InvalidPluginName {
132 name: name.to_owned(),
133 });
134 }
135 }
136 let lower = name.to_ascii_lowercase();
137 if Self::WINDOWS_RESERVED.iter().any(|&r| r == lower) {
138 return Err(SchemaError::ReservedPluginName {
139 name: name.to_owned(),
140 });
141 }
142 Ok(())
143 }
144}
145
146pub(crate) fn canonical_name(raw: &str) -> String {
152 raw.to_ascii_lowercase().replace('-', "_")
153}
154
155impl FromStr for PluginName {
156 type Err = SchemaError;
157
158 fn from_str(s: &str) -> Result<Self, Self::Err> {
159 Self::validate(s)?;
160 Ok(Self(s.to_owned()))
161 }
162}
163
164impl fmt::Display for PluginName {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 f.write_str(&self.0)
167 }
168}
169
170impl<'de> serde::Deserialize<'de> for PluginName {
171 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172 where
173 D: serde::Deserializer<'de>,
174 {
175 let raw = String::deserialize(deserializer)?;
176 Self::from_str(&raw).map_err(serde::de::Error::custom)
177 }
178}
179
180impl serde::Serialize for PluginName {
181 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
182 where
183 S: serde::Serializer,
184 {
185 serializer.serialize_str(&self.0)
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub enum PluginId {
198 Registry {
199 index_url: IndexUrl,
200 name: PluginName,
201 version: semver::Version,
202 },
203 Local {
204 path: std::path::PathBuf,
205 name: PluginName,
206 version: semver::Version,
207 },
208}
209
210impl PluginId {
211 pub fn registry(index_url: IndexUrl, name: PluginName, version: semver::Version) -> Self {
212 Self::Registry {
213 index_url,
214 name,
215 version,
216 }
217 }
218
219 pub fn local(path: std::path::PathBuf, name: PluginName, version: semver::Version) -> Self {
220 Self::Local {
221 path,
222 name,
223 version,
224 }
225 }
226
227 pub fn name(&self) -> &PluginName {
228 match self {
229 Self::Registry { name, .. } | Self::Local { name, .. } => name,
230 }
231 }
232
233 pub fn version(&self) -> &semver::Version {
234 match self {
235 Self::Registry { version, .. } | Self::Local { version, .. } => version,
236 }
237 }
238}
239
240impl fmt::Display for PluginId {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 match self {
243 Self::Registry {
244 index_url,
245 name,
246 version,
247 } => write!(f, "{name}@{version} ({index_url})"),
248 Self::Local {
249 path,
250 name,
251 version,
252 } => write!(f, "{name}@{version} (local: {})", path.display()),
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use assert_matches::assert_matches;
261 use rstest::rstest;
262
263 #[rstest]
264 #[case("a")]
265 #[case("aa")]
266 #[case("plugin")]
267 #[case("my-plugin")]
268 #[case("a1b2c3")]
269 #[case("a-really-long-but-still-valid-name-that-is-under-64-chars")]
270 #[case("Z")]
271 #[case("MyPlugin")]
272 #[case("MYPLUGIN")]
273 #[case("Test-1_v2")]
274 #[case("Foo")]
275 #[case("foo_bar")]
276 fn valid_names_accepted(#[case] input: &str) {
277 let name = PluginName::from_str(input).expect("should accept valid name");
278 assert_eq!(name.as_str(), input);
279 }
280
281 #[rstest]
282 #[case("")] #[case("-foo")] #[case("foo bar")] #[case("123")] #[case("7plugin")] #[case("café")] #[case("foo.bar")] fn invalid_names_rejected(#[case] input: &str) {
290 let err = PluginName::from_str(input).expect_err("should reject");
291 assert_matches!(err, SchemaError::InvalidPluginName { .. });
292 }
293
294 #[test]
295 fn plugin_name_length_boundaries() {
296 assert!(PluginName::from_str("a").is_ok());
297 assert!(PluginName::from_str(&"a".repeat(64)).is_ok());
298 assert!(matches!(
299 PluginName::from_str(&"a".repeat(65)),
300 Err(SchemaError::InvalidPluginName { .. })
301 ));
302 assert!(matches!(
303 PluginName::from_str(""),
304 Err(SchemaError::InvalidPluginName { .. })
305 ));
306 }
307
308 #[rstest]
309 #[case("con")]
310 #[case("prn")]
311 #[case("aux")]
312 #[case("nul")]
313 #[case("com0")]
314 #[case("com9")]
315 #[case("lpt0")]
316 #[case("lpt9")]
317 #[case("CON")]
318 #[case("Com1")]
319 fn reserved_names_rejected(#[case] input: &str) {
320 let err = PluginName::from_str(input).expect_err("should reject reserved name");
321 assert!(
322 matches!(err, SchemaError::ReservedPluginName { ref name } if name == input),
323 "expected ReservedPluginName with preserved input spelling, got: {err:?}"
324 );
325 }
326
327 #[rstest]
328 #[case("console")]
329 #[case("com10")]
330 #[case("conin")]
331 #[case("com")]
332 fn near_reserved_names_accepted(#[case] input: &str) {
333 assert!(PluginName::from_str(input).is_ok());
334 }
335
336 #[test]
337 fn plugin_name_display_matches_as_str() {
338 let name = PluginName::from_str("downsampler").unwrap();
339 assert_eq!(format!("{name}"), "downsampler");
340 }
341
342 #[test]
343 fn plugin_name_round_trips_through_serde_json() {
344 let name = PluginName::from_str("my-plugin").unwrap();
345 let json = serde_json::to_string(&name).unwrap();
346 assert_eq!(json, "\"my-plugin\"");
347 let back: PluginName = serde_json::from_str(&json).unwrap();
348 assert_eq!(back, name);
349 }
350
351 #[test]
352 fn plugin_name_deserialize_rejects_invalid() {
353 let result: Result<PluginName, _> = serde_json::from_str("\"Bad Name\"");
354 let err = result.expect_err("should reject invalid name");
355 assert!(
360 err.to_string().contains("plugin name"),
361 "expected error mentioning plugin name, got: {err}"
362 );
363 }
364
365 #[rstest]
366 #[case("a", "a")]
367 #[case("MyPlugin", "myplugin")]
368 #[case("foo-bar", "foo_bar")]
369 #[case("foo_bar", "foo_bar")]
370 #[case("Foo-Bar_Baz", "foo_bar_baz")]
371 #[case("Test-1_v2", "test_1_v2")]
372 fn canonical_form_matches_table(#[case] input: &str, #[case] expected: &str) {
373 let name = PluginName::from_str(input).expect("valid input");
374 assert_eq!(name.canonical(), expected);
375 assert_eq!(name.as_str(), input);
377 }
378}
379
380#[cfg(test)]
381mod index_url_tests {
382 use super::*;
383 use assert_matches::assert_matches;
384 use rstest::rstest;
385
386 #[rstest]
387 #[case("https://plugins.example.com/index.json")]
388 #[case("http://localhost:8080/index.json")]
389 #[case("file:///srv/registry/index.json")]
390 fn valid_schemes_accepted(#[case] input: &str) {
391 assert!(IndexUrl::try_new(input).is_ok());
392 }
393
394 #[rstest]
395 #[case("s3://bucket/index.json")]
396 #[case("oci://registry.example")]
397 #[case("ftp://registry.example/index.json")]
398 fn invalid_schemes_rejected(#[case] input: &str) {
399 assert_matches!(
400 IndexUrl::try_new(input),
401 Err(SchemaError::UnsupportedIndexUrlScheme { .. })
402 );
403 }
404
405 #[test]
406 fn malformed_rejected() {
407 assert_matches!(
408 IndexUrl::try_new("not a url"),
409 Err(SchemaError::InvalidUrl { .. })
410 );
411 }
412}
413
414#[cfg(test)]
415mod plugin_id_tests {
416 use super::*;
417 use pretty_assertions::assert_eq;
418 use semver::Version;
419 use std::path::PathBuf;
420
421 #[test]
422 fn registry_variant_constructs_from_parts() {
423 let id = PluginId::registry(
424 IndexUrl::try_new("https://plugins.example.com/index.json").unwrap(),
425 PluginName::from_str("downsampler").unwrap(),
426 Version::new(1, 2, 0),
427 );
428 match &id {
429 PluginId::Registry { name, version, .. } => {
430 assert_eq!(name.as_str(), "downsampler");
431 assert_eq!(*version, Version::new(1, 2, 0));
432 }
433 PluginId::Local { .. } => panic!("expected Registry variant"),
434 }
435 assert_eq!(id.name().as_str(), "downsampler");
436 assert_eq!(*id.version(), Version::new(1, 2, 0));
437 }
438
439 #[test]
440 fn local_variant_constructs_from_parts() {
441 let id = PluginId::local(
442 PathBuf::from("/srv/plugins/my-plugin"),
443 PluginName::from_str("my-plugin").unwrap(),
444 Version::new(0, 3, 1),
445 );
446 match &id {
447 PluginId::Local {
448 path,
449 name,
450 version,
451 } => {
452 assert_eq!(*path, PathBuf::from("/srv/plugins/my-plugin"));
453 assert_eq!(name.as_str(), "my-plugin");
454 assert_eq!(*version, Version::new(0, 3, 1));
455 }
456 PluginId::Registry { .. } => panic!("expected Local variant"),
457 }
458 assert_eq!(id.name().as_str(), "my-plugin");
459 assert_eq!(*id.version(), Version::new(0, 3, 1));
460 }
461
462 #[test]
463 fn display_shape_pinned() {
464 let registry = PluginId::registry(
465 IndexUrl::try_new("https://r.example/index.json").unwrap(),
466 PluginName::from_str("downsampler").unwrap(),
467 Version::new(1, 2, 0),
468 );
469 let local = PluginId::local(
470 PathBuf::from("/srv/plugins/my-plugin"),
471 PluginName::from_str("my-plugin").unwrap(),
472 Version::new(0, 3, 1),
473 );
474 insta::assert_yaml_snapshot!(
475 "plugin_id_display",
476 vec![registry.to_string(), local.to_string()]
477 );
478 }
479}