1use std::fmt;
2
3use semver::Version;
4use serde::{de, ser};
5use url::Url;
6
7use crate::core::GitReference;
8use crate::core::PartialVersion;
9use crate::core::PartialVersionError;
10use crate::core::SourceKind;
11use crate::manifest::PackageName;
12use crate::restricted_names::NameValidationError;
13
14type Result<T> = std::result::Result<T, PackageIdSpecError>;
15
16#[derive(Clone, PartialEq, Eq, Debug, Hash, Ord, PartialOrd)]
26pub struct PackageIdSpec {
27 name: String,
28 version: Option<PartialVersion>,
29 url: Option<Url>,
30 kind: Option<SourceKind>,
31}
32
33impl PackageIdSpec {
34 pub fn new(name: String) -> Self {
35 Self {
36 name,
37 version: None,
38 url: None,
39 kind: None,
40 }
41 }
42
43 pub fn with_version(mut self, version: PartialVersion) -> Self {
44 self.version = Some(version);
45 self
46 }
47
48 pub fn with_url(mut self, url: Url) -> Self {
49 self.url = Some(url);
50 self
51 }
52
53 pub fn with_kind(mut self, kind: SourceKind) -> Self {
54 self.kind = Some(kind);
55 self
56 }
57
58 pub fn parse(spec: &str) -> Result<PackageIdSpec> {
81 if spec.contains("://") {
82 if let Ok(url) = Url::parse(spec) {
83 return PackageIdSpec::from_url(url);
84 }
85 } else if spec.contains('/') || spec.contains('\\') {
86 let abs = std::env::current_dir().unwrap_or_default().join(spec);
87 if abs.exists() {
88 let maybe_url = Url::from_file_path(abs)
89 .map_or_else(|_| "a file:// URL".to_string(), |url| url.to_string());
90 return Err(ErrorKind::MaybeFilePath {
91 spec: spec.into(),
92 maybe_url,
93 }
94 .into());
95 }
96 }
97 let (name, version) = parse_spec(spec)?.unwrap_or_else(|| (spec.to_owned(), None));
98 PackageName::new(&name)?;
99 Ok(PackageIdSpec {
100 name: String::from(name),
101 version,
102 url: None,
103 kind: None,
104 })
105 }
106
107 fn from_url(mut url: Url) -> Result<PackageIdSpec> {
109 let mut kind = None;
110 if let Some((kind_str, scheme)) = url.scheme().split_once('+') {
111 match kind_str {
112 "git" => {
113 let git_ref = GitReference::from_query(url.query_pairs());
114 url.set_query(None);
115 kind = Some(SourceKind::Git(git_ref));
116 url = strip_url_protocol(&url);
117 }
118 "registry" => {
119 if url.query().is_some() {
120 return Err(ErrorKind::UnexpectedQueryString(url).into());
121 }
122 kind = Some(SourceKind::Registry);
123 url = strip_url_protocol(&url);
124 }
125 "sparse" => {
126 if url.query().is_some() {
127 return Err(ErrorKind::UnexpectedQueryString(url).into());
128 }
129 kind = Some(SourceKind::SparseRegistry);
130 }
133 "path" => {
134 if url.query().is_some() {
135 return Err(ErrorKind::UnexpectedQueryString(url).into());
136 }
137 if scheme != "file" {
138 return Err(ErrorKind::UnsupportedPathPlusScheme(scheme.into()).into());
139 }
140 kind = Some(SourceKind::Path);
141 url = strip_url_protocol(&url);
142 }
143 kind => return Err(ErrorKind::UnsupportedProtocol(kind.into()).into()),
144 }
145 } else {
146 if url.query().is_some() {
147 return Err(ErrorKind::UnexpectedQueryString(url).into());
148 }
149 }
150
151 let frag = url.fragment().map(|s| s.to_owned());
152 url.set_fragment(None);
153
154 let (name, version) = {
155 let Some(path_name) = url.path_segments().and_then(|mut p| p.next_back()) else {
156 return Err(ErrorKind::MissingUrlPath(url).into());
157 };
158 match frag {
159 Some(fragment) => match parse_spec(&fragment)? {
160 Some((name, ver)) => (name, ver),
161 None => {
162 if fragment.chars().next().unwrap().is_alphabetic() {
163 (String::from(fragment.as_str()), None)
164 } else {
165 let version = fragment.parse::<PartialVersion>()?;
166 (String::from(path_name), Some(version))
167 }
168 }
169 },
170 None => (String::from(path_name), None),
171 }
172 };
173 PackageName::new(&name)?;
174 Ok(PackageIdSpec {
175 name,
176 version,
177 url: Some(url),
178 kind,
179 })
180 }
181
182 pub fn name(&self) -> &str {
183 self.name.as_str()
184 }
185
186 pub fn version(&self) -> Option<Version> {
188 self.version.as_ref().and_then(|v| v.to_version())
189 }
190
191 pub fn partial_version(&self) -> Option<&PartialVersion> {
192 self.version.as_ref()
193 }
194
195 pub fn url(&self) -> Option<&Url> {
196 self.url.as_ref()
197 }
198
199 pub fn set_url(&mut self, url: Url) {
200 self.url = Some(url);
201 }
202
203 pub fn kind(&self) -> Option<&SourceKind> {
204 self.kind.as_ref()
205 }
206
207 pub fn set_kind(&mut self, kind: SourceKind) {
208 self.kind = Some(kind);
209 }
210}
211
212fn parse_spec(spec: &str) -> Result<Option<(String, Option<PartialVersion>)>> {
213 let Some((name, ver)) = spec
214 .rsplit_once('@')
215 .or_else(|| spec.rsplit_once(':').filter(|(n, _)| !n.ends_with(':')))
216 else {
217 return Ok(None);
218 };
219 let name = name.to_owned();
220 let ver = ver.parse::<PartialVersion>()?;
221 Ok(Some((name, Some(ver))))
222}
223
224fn strip_url_protocol(url: &Url) -> Url {
225 let raw = url.to_string();
227 raw.split_once('+').unwrap().1.parse().unwrap()
228}
229
230impl fmt::Display for PackageIdSpec {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 let mut printed_name = false;
233 match self.url {
234 Some(ref url) => {
235 if let Some(protocol) = self.kind.as_ref().and_then(|k| k.protocol()) {
236 write!(f, "{protocol}+")?;
237 }
238 write!(f, "{}", url)?;
239 if let Some(SourceKind::Git(git_ref)) = self.kind.as_ref() {
240 if let Some(pretty) = git_ref.pretty_ref(true) {
241 write!(f, "?{}", pretty)?;
242 }
243 }
244 if url.path_segments().unwrap().next_back().unwrap() != &*self.name {
245 printed_name = true;
246 write!(f, "#{}", self.name)?;
247 }
248 }
249 None => {
250 printed_name = true;
251 write!(f, "{}", self.name)?;
252 }
253 }
254 if let Some(ref v) = self.version {
255 write!(f, "{}{}", if printed_name { "@" } else { "#" }, v)?;
256 }
257 Ok(())
258 }
259}
260
261impl ser::Serialize for PackageIdSpec {
262 fn serialize<S>(&self, s: S) -> std::result::Result<S::Ok, S::Error>
263 where
264 S: ser::Serializer,
265 {
266 self.to_string().serialize(s)
267 }
268}
269
270impl<'de> de::Deserialize<'de> for PackageIdSpec {
271 fn deserialize<D>(d: D) -> std::result::Result<PackageIdSpec, D::Error>
272 where
273 D: de::Deserializer<'de>,
274 {
275 let string = String::deserialize(d)?;
276 PackageIdSpec::parse(&string).map_err(de::Error::custom)
277 }
278}
279
280#[derive(Debug, thiserror::Error)]
282#[error(transparent)]
283pub struct PackageIdSpecError(#[from] ErrorKind);
284
285impl From<PartialVersionError> for PackageIdSpecError {
286 fn from(value: PartialVersionError) -> Self {
287 ErrorKind::PartialVersion(value).into()
288 }
289}
290
291impl From<NameValidationError> for PackageIdSpecError {
292 fn from(value: NameValidationError) -> Self {
293 ErrorKind::NameValidation(value).into()
294 }
295}
296
297#[non_exhaustive]
299#[derive(Debug, thiserror::Error)]
300enum ErrorKind {
301 #[error("unsupported source protocol: {0}")]
302 UnsupportedProtocol(String),
303
304 #[error("`path+{0}` is unsupported; `path+file` and `file` schemes are supported")]
305 UnsupportedPathPlusScheme(String),
306
307 #[error("cannot have a query string in a pkgid: {0}")]
308 UnexpectedQueryString(Url),
309
310 #[error("pkgid urls must have at least one path component: {0}")]
311 MissingUrlPath(Url),
312
313 #[error("package ID specification `{spec}` looks like a file path, maybe try {maybe_url}")]
314 MaybeFilePath { spec: String, maybe_url: String },
315
316 #[error(transparent)]
317 NameValidation(#[from] crate::restricted_names::NameValidationError),
318
319 #[error(transparent)]
320 PartialVersion(#[from] crate::core::PartialVersionError),
321}
322
323#[cfg(test)]
324mod tests {
325 use super::ErrorKind;
326 use super::PackageIdSpec;
327 use crate::core::{GitReference, SourceKind};
328 use url::Url;
329
330 #[track_caller]
331 fn ok(spec: &str, expected: PackageIdSpec, expected_rendered: &str) {
332 let parsed = PackageIdSpec::parse(spec).unwrap();
333 assert_eq!(parsed, expected);
334 let rendered = parsed.to_string();
335 assert_eq!(rendered, expected_rendered);
336 let reparsed = PackageIdSpec::parse(&rendered).unwrap();
337 assert_eq!(reparsed, expected);
338 }
339
340 macro_rules! err {
341 ($spec:expr, $expected:pat) => {
342 let err = PackageIdSpec::parse($spec).unwrap_err();
343 let kind = err.0;
344 assert!(
345 matches!(kind, $expected),
346 "`{}` parse error mismatch, got {kind:?}",
347 $spec
348 );
349 };
350 }
351
352 #[test]
353 fn good_parsing() {
354 ok(
355 "https://crates.io/foo",
356 PackageIdSpec {
357 name: String::from("foo"),
358 version: None,
359 url: Some(Url::parse("https://crates.io/foo").unwrap()),
360 kind: None,
361 },
362 "https://crates.io/foo",
363 );
364 ok(
365 "https://crates.io/foo#1.2.3",
366 PackageIdSpec {
367 name: String::from("foo"),
368 version: Some("1.2.3".parse().unwrap()),
369 url: Some(Url::parse("https://crates.io/foo").unwrap()),
370 kind: None,
371 },
372 "https://crates.io/foo#1.2.3",
373 );
374 ok(
375 "https://crates.io/foo#1.2",
376 PackageIdSpec {
377 name: String::from("foo"),
378 version: Some("1.2".parse().unwrap()),
379 url: Some(Url::parse("https://crates.io/foo").unwrap()),
380 kind: None,
381 },
382 "https://crates.io/foo#1.2",
383 );
384 ok(
385 "https://crates.io/foo#bar:1.2.3",
386 PackageIdSpec {
387 name: String::from("bar"),
388 version: Some("1.2.3".parse().unwrap()),
389 url: Some(Url::parse("https://crates.io/foo").unwrap()),
390 kind: None,
391 },
392 "https://crates.io/foo#bar@1.2.3",
393 );
394 ok(
395 "https://crates.io/foo#bar@1.2.3",
396 PackageIdSpec {
397 name: String::from("bar"),
398 version: Some("1.2.3".parse().unwrap()),
399 url: Some(Url::parse("https://crates.io/foo").unwrap()),
400 kind: None,
401 },
402 "https://crates.io/foo#bar@1.2.3",
403 );
404 ok(
405 "https://crates.io/foo#bar@1.2",
406 PackageIdSpec {
407 name: String::from("bar"),
408 version: Some("1.2".parse().unwrap()),
409 url: Some(Url::parse("https://crates.io/foo").unwrap()),
410 kind: None,
411 },
412 "https://crates.io/foo#bar@1.2",
413 );
414 ok(
415 "registry+https://crates.io/foo#bar@1.2",
416 PackageIdSpec {
417 name: String::from("bar"),
418 version: Some("1.2".parse().unwrap()),
419 url: Some(Url::parse("https://crates.io/foo").unwrap()),
420 kind: Some(SourceKind::Registry),
421 },
422 "registry+https://crates.io/foo#bar@1.2",
423 );
424 ok(
425 "sparse+https://crates.io/foo#bar@1.2",
426 PackageIdSpec {
427 name: String::from("bar"),
428 version: Some("1.2".parse().unwrap()),
429 url: Some(Url::parse("sparse+https://crates.io/foo").unwrap()),
430 kind: Some(SourceKind::SparseRegistry),
431 },
432 "sparse+https://crates.io/foo#bar@1.2",
433 );
434 ok(
435 "foo",
436 PackageIdSpec {
437 name: String::from("foo"),
438 version: None,
439 url: None,
440 kind: None,
441 },
442 "foo",
443 );
444 ok(
445 "foo::bar",
446 PackageIdSpec {
447 name: String::from("foo::bar"),
448 version: None,
449 url: None,
450 kind: None,
451 },
452 "foo::bar",
453 );
454 ok(
455 "foo:1.2.3",
456 PackageIdSpec {
457 name: String::from("foo"),
458 version: Some("1.2.3".parse().unwrap()),
459 url: None,
460 kind: None,
461 },
462 "foo@1.2.3",
463 );
464 ok(
465 "foo::bar:1.2.3",
466 PackageIdSpec {
467 name: String::from("foo::bar"),
468 version: Some("1.2.3".parse().unwrap()),
469 url: None,
470 kind: None,
471 },
472 "foo::bar@1.2.3",
473 );
474 ok(
475 "foo@1.2.3",
476 PackageIdSpec {
477 name: String::from("foo"),
478 version: Some("1.2.3".parse().unwrap()),
479 url: None,
480 kind: None,
481 },
482 "foo@1.2.3",
483 );
484 ok(
485 "foo::bar@1.2.3",
486 PackageIdSpec {
487 name: String::from("foo::bar"),
488 version: Some("1.2.3".parse().unwrap()),
489 url: None,
490 kind: None,
491 },
492 "foo::bar@1.2.3",
493 );
494 ok(
495 "foo@1.2",
496 PackageIdSpec {
497 name: String::from("foo"),
498 version: Some("1.2".parse().unwrap()),
499 url: None,
500 kind: None,
501 },
502 "foo@1.2",
503 );
504
505 ok(
507 "regex",
508 PackageIdSpec {
509 name: String::from("regex"),
510 version: None,
511 url: None,
512 kind: None,
513 },
514 "regex",
515 );
516 ok(
517 "regex@1.4",
518 PackageIdSpec {
519 name: String::from("regex"),
520 version: Some("1.4".parse().unwrap()),
521 url: None,
522 kind: None,
523 },
524 "regex@1.4",
525 );
526 ok(
527 "regex@1.4.3",
528 PackageIdSpec {
529 name: String::from("regex"),
530 version: Some("1.4.3".parse().unwrap()),
531 url: None,
532 kind: None,
533 },
534 "regex@1.4.3",
535 );
536 ok(
537 "https://github.com/rust-lang/crates.io-index#regex",
538 PackageIdSpec {
539 name: String::from("regex"),
540 version: None,
541 url: Some(Url::parse("https://github.com/rust-lang/crates.io-index").unwrap()),
542 kind: None,
543 },
544 "https://github.com/rust-lang/crates.io-index#regex",
545 );
546 ok(
547 "https://github.com/rust-lang/crates.io-index#regex@1.4.3",
548 PackageIdSpec {
549 name: String::from("regex"),
550 version: Some("1.4.3".parse().unwrap()),
551 url: Some(Url::parse("https://github.com/rust-lang/crates.io-index").unwrap()),
552 kind: None,
553 },
554 "https://github.com/rust-lang/crates.io-index#regex@1.4.3",
555 );
556 ok(
557 "sparse+https://github.com/rust-lang/crates.io-index#regex@1.4.3",
558 PackageIdSpec {
559 name: String::from("regex"),
560 version: Some("1.4.3".parse().unwrap()),
561 url: Some(
562 Url::parse("sparse+https://github.com/rust-lang/crates.io-index").unwrap(),
563 ),
564 kind: Some(SourceKind::SparseRegistry),
565 },
566 "sparse+https://github.com/rust-lang/crates.io-index#regex@1.4.3",
567 );
568 ok(
569 "https://github.com/rust-lang/cargo#0.52.0",
570 PackageIdSpec {
571 name: String::from("cargo"),
572 version: Some("0.52.0".parse().unwrap()),
573 url: Some(Url::parse("https://github.com/rust-lang/cargo").unwrap()),
574 kind: None,
575 },
576 "https://github.com/rust-lang/cargo#0.52.0",
577 );
578 ok(
579 "https://github.com/rust-lang/cargo#cargo-platform@0.1.2",
580 PackageIdSpec {
581 name: String::from("cargo-platform"),
582 version: Some("0.1.2".parse().unwrap()),
583 url: Some(Url::parse("https://github.com/rust-lang/cargo").unwrap()),
584 kind: None,
585 },
586 "https://github.com/rust-lang/cargo#cargo-platform@0.1.2",
587 );
588 ok(
589 "ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
590 PackageIdSpec {
591 name: String::from("regex"),
592 version: Some("1.4.3".parse().unwrap()),
593 url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
594 kind: None,
595 },
596 "ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
597 );
598 ok(
599 "git+ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
600 PackageIdSpec {
601 name: String::from("regex"),
602 version: Some("1.4.3".parse().unwrap()),
603 url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
604 kind: Some(SourceKind::Git(GitReference::DefaultBranch)),
605 },
606 "git+ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
607 );
608 ok(
609 "git+ssh://git@github.com/rust-lang/regex.git?branch=dev#regex@1.4.3",
610 PackageIdSpec {
611 name: String::from("regex"),
612 version: Some("1.4.3".parse().unwrap()),
613 url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
614 kind: Some(SourceKind::Git(GitReference::Branch("dev".to_owned()))),
615 },
616 "git+ssh://git@github.com/rust-lang/regex.git?branch=dev#regex@1.4.3",
617 );
618 ok(
619 "file:///path/to/my/project/foo",
620 PackageIdSpec {
621 name: String::from("foo"),
622 version: None,
623 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
624 kind: None,
625 },
626 "file:///path/to/my/project/foo",
627 );
628 ok(
629 "file:///path/to/my/project/foo::bar",
630 PackageIdSpec {
631 name: String::from("foo::bar"),
632 version: None,
633 url: Some(Url::parse("file:///path/to/my/project/foo::bar").unwrap()),
634 kind: None,
635 },
636 "file:///path/to/my/project/foo::bar",
637 );
638 ok(
639 "file:///path/to/my/project/foo#1.1.8",
640 PackageIdSpec {
641 name: String::from("foo"),
642 version: Some("1.1.8".parse().unwrap()),
643 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
644 kind: None,
645 },
646 "file:///path/to/my/project/foo#1.1.8",
647 );
648 ok(
649 "path+file:///path/to/my/project/foo#1.1.8",
650 PackageIdSpec {
651 name: String::from("foo"),
652 version: Some("1.1.8".parse().unwrap()),
653 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
654 kind: Some(SourceKind::Path),
655 },
656 "path+file:///path/to/my/project/foo#1.1.8",
657 );
658 ok(
659 "path+file:///path/to/my/project/foo#bar",
660 PackageIdSpec {
661 name: String::from("bar"),
662 version: None,
663 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
664 kind: Some(SourceKind::Path),
665 },
666 "path+file:///path/to/my/project/foo#bar",
667 );
668 ok(
669 "path+file:///path/to/my/project/foo#foo::bar",
670 PackageIdSpec {
671 name: String::from("foo::bar"),
672 version: None,
673 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
674 kind: Some(SourceKind::Path),
675 },
676 "path+file:///path/to/my/project/foo#foo::bar",
677 );
678 ok(
679 "path+file:///path/to/my/project/foo#bar:1.1.8",
680 PackageIdSpec {
681 name: String::from("bar"),
682 version: Some("1.1.8".parse().unwrap()),
683 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
684 kind: Some(SourceKind::Path),
685 },
686 "path+file:///path/to/my/project/foo#bar@1.1.8",
687 );
688 ok(
689 "path+file:///path/to/my/project/foo#foo::bar:1.1.8",
690 PackageIdSpec {
691 name: String::from("foo::bar"),
692 version: Some("1.1.8".parse().unwrap()),
693 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
694 kind: Some(SourceKind::Path),
695 },
696 "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
697 );
698 ok(
699 "path+file:///path/to/my/project/foo#bar@1.1.8",
700 PackageIdSpec {
701 name: String::from("bar"),
702 version: Some("1.1.8".parse().unwrap()),
703 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
704 kind: Some(SourceKind::Path),
705 },
706 "path+file:///path/to/my/project/foo#bar@1.1.8",
707 );
708 ok(
709 "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
710 PackageIdSpec {
711 name: String::from("foo::bar"),
712 version: Some("1.1.8".parse().unwrap()),
713 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
714 kind: Some(SourceKind::Path),
715 },
716 "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
717 );
718 }
719
720 #[test]
721 fn bad_parsing() {
722 err!("baz:", ErrorKind::PartialVersion(_));
723 err!("baz:*", ErrorKind::PartialVersion(_));
724 err!("baz@", ErrorKind::PartialVersion(_));
725 err!("baz@*", ErrorKind::PartialVersion(_));
726 err!("baz@^1.0", ErrorKind::PartialVersion(_));
727 err!("https://baz:1.0", ErrorKind::NameValidation(_));
728 err!("https://#baz:1.0", ErrorKind::NameValidation(_));
729 err!(
730 "foobar+https://github.com/rust-lang/crates.io-index",
731 ErrorKind::UnsupportedProtocol(_)
732 );
733 err!(
734 "path+https://github.com/rust-lang/crates.io-index",
735 ErrorKind::UnsupportedPathPlusScheme(_)
736 );
737
738 err!(
740 "file:///path/to/my/project/foo?branch=dev",
741 ErrorKind::UnexpectedQueryString(_)
742 );
743 err!(
744 "path+file:///path/to/my/project/foo?branch=dev",
745 ErrorKind::UnexpectedQueryString(_)
746 );
747 err!(
748 "registry+https://github.com/rust-lang/cargo?branch=dev#0.52.0",
749 ErrorKind::UnexpectedQueryString(_)
750 );
751 err!(
752 "sparse+https://github.com/rust-lang/cargo?branch=dev#0.52.0",
753 ErrorKind::UnexpectedQueryString(_)
754 );
755 err!("@1.2.3", ErrorKind::NameValidation(_));
756 err!("registry+https://github.com", ErrorKind::NameValidation(_));
757 err!("https://crates.io/1foo#1.2.3", ErrorKind::NameValidation(_));
758 }
759}