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#[cfg(feature = "unstable-schema")]
281impl schemars::JsonSchema for PackageIdSpec {
282 fn schema_name() -> std::borrow::Cow<'static, str> {
283 "PackageIdSpec".into()
284 }
285 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
286 <String as schemars::JsonSchema>::json_schema(generator)
287 }
288}
289
290#[derive(Debug, thiserror::Error)]
291#[error(transparent)]
292pub struct PackageIdSpecError(#[from] ErrorKind);
293
294impl From<PartialVersionError> for PackageIdSpecError {
295 fn from(value: PartialVersionError) -> Self {
296 ErrorKind::PartialVersion(value).into()
297 }
298}
299
300impl From<NameValidationError> for PackageIdSpecError {
301 fn from(value: NameValidationError) -> Self {
302 ErrorKind::NameValidation(value).into()
303 }
304}
305
306#[non_exhaustive]
308#[derive(Debug, thiserror::Error)]
309enum ErrorKind {
310 #[error("unsupported source protocol: {0}")]
311 UnsupportedProtocol(String),
312
313 #[error("`path+{0}` is unsupported; `path+file` and `file` schemes are supported")]
314 UnsupportedPathPlusScheme(String),
315
316 #[error("cannot have a query string in a pkgid: {0}")]
317 UnexpectedQueryString(Url),
318
319 #[error("pkgid urls must have at least one path component: {0}")]
320 MissingUrlPath(Url),
321
322 #[error("package ID specification `{spec}` looks like a file path, maybe try {maybe_url}")]
323 MaybeFilePath { spec: String, maybe_url: String },
324
325 #[error(transparent)]
326 NameValidation(#[from] crate::restricted_names::NameValidationError),
327
328 #[error(transparent)]
329 PartialVersion(#[from] crate::core::PartialVersionError),
330}
331
332#[cfg(test)]
333mod tests {
334 use super::ErrorKind;
335 use super::PackageIdSpec;
336 use crate::core::{GitReference, SourceKind};
337 use url::Url;
338
339 #[track_caller]
340 fn ok(spec: &str, expected: PackageIdSpec, expected_rendered: &str) {
341 let parsed = PackageIdSpec::parse(spec).unwrap();
342 assert_eq!(parsed, expected);
343 let rendered = parsed.to_string();
344 assert_eq!(rendered, expected_rendered);
345 let reparsed = PackageIdSpec::parse(&rendered).unwrap();
346 assert_eq!(reparsed, expected);
347 }
348
349 macro_rules! err {
350 ($spec:expr, $expected:pat) => {
351 let err = PackageIdSpec::parse($spec).unwrap_err();
352 let kind = err.0;
353 assert!(
354 matches!(kind, $expected),
355 "`{}` parse error mismatch, got {kind:?}",
356 $spec
357 );
358 };
359 }
360
361 #[test]
362 fn good_parsing() {
363 ok(
364 "https://crates.io/foo",
365 PackageIdSpec {
366 name: String::from("foo"),
367 version: None,
368 url: Some(Url::parse("https://crates.io/foo").unwrap()),
369 kind: None,
370 },
371 "https://crates.io/foo",
372 );
373 ok(
374 "https://crates.io/foo#1.2.3",
375 PackageIdSpec {
376 name: String::from("foo"),
377 version: Some("1.2.3".parse().unwrap()),
378 url: Some(Url::parse("https://crates.io/foo").unwrap()),
379 kind: None,
380 },
381 "https://crates.io/foo#1.2.3",
382 );
383 ok(
384 "https://crates.io/foo#1.2",
385 PackageIdSpec {
386 name: String::from("foo"),
387 version: Some("1.2".parse().unwrap()),
388 url: Some(Url::parse("https://crates.io/foo").unwrap()),
389 kind: None,
390 },
391 "https://crates.io/foo#1.2",
392 );
393 ok(
394 "https://crates.io/foo#bar:1.2.3",
395 PackageIdSpec {
396 name: String::from("bar"),
397 version: Some("1.2.3".parse().unwrap()),
398 url: Some(Url::parse("https://crates.io/foo").unwrap()),
399 kind: None,
400 },
401 "https://crates.io/foo#bar@1.2.3",
402 );
403 ok(
404 "https://crates.io/foo#bar@1.2.3",
405 PackageIdSpec {
406 name: String::from("bar"),
407 version: Some("1.2.3".parse().unwrap()),
408 url: Some(Url::parse("https://crates.io/foo").unwrap()),
409 kind: None,
410 },
411 "https://crates.io/foo#bar@1.2.3",
412 );
413 ok(
414 "https://crates.io/foo#bar@1.2",
415 PackageIdSpec {
416 name: String::from("bar"),
417 version: Some("1.2".parse().unwrap()),
418 url: Some(Url::parse("https://crates.io/foo").unwrap()),
419 kind: None,
420 },
421 "https://crates.io/foo#bar@1.2",
422 );
423 ok(
424 "registry+https://crates.io/foo#bar@1.2",
425 PackageIdSpec {
426 name: String::from("bar"),
427 version: Some("1.2".parse().unwrap()),
428 url: Some(Url::parse("https://crates.io/foo").unwrap()),
429 kind: Some(SourceKind::Registry),
430 },
431 "registry+https://crates.io/foo#bar@1.2",
432 );
433 ok(
434 "sparse+https://crates.io/foo#bar@1.2",
435 PackageIdSpec {
436 name: String::from("bar"),
437 version: Some("1.2".parse().unwrap()),
438 url: Some(Url::parse("sparse+https://crates.io/foo").unwrap()),
439 kind: Some(SourceKind::SparseRegistry),
440 },
441 "sparse+https://crates.io/foo#bar@1.2",
442 );
443 ok(
444 "foo",
445 PackageIdSpec {
446 name: String::from("foo"),
447 version: None,
448 url: None,
449 kind: None,
450 },
451 "foo",
452 );
453 ok(
454 "foo::bar",
455 PackageIdSpec {
456 name: String::from("foo::bar"),
457 version: None,
458 url: None,
459 kind: None,
460 },
461 "foo::bar",
462 );
463 ok(
464 "foo:1.2.3",
465 PackageIdSpec {
466 name: String::from("foo"),
467 version: Some("1.2.3".parse().unwrap()),
468 url: None,
469 kind: None,
470 },
471 "foo@1.2.3",
472 );
473 ok(
474 "foo::bar:1.2.3",
475 PackageIdSpec {
476 name: String::from("foo::bar"),
477 version: Some("1.2.3".parse().unwrap()),
478 url: None,
479 kind: None,
480 },
481 "foo::bar@1.2.3",
482 );
483 ok(
484 "foo@1.2.3",
485 PackageIdSpec {
486 name: String::from("foo"),
487 version: Some("1.2.3".parse().unwrap()),
488 url: None,
489 kind: None,
490 },
491 "foo@1.2.3",
492 );
493 ok(
494 "foo::bar@1.2.3",
495 PackageIdSpec {
496 name: String::from("foo::bar"),
497 version: Some("1.2.3".parse().unwrap()),
498 url: None,
499 kind: None,
500 },
501 "foo::bar@1.2.3",
502 );
503 ok(
504 "foo@1.2",
505 PackageIdSpec {
506 name: String::from("foo"),
507 version: Some("1.2".parse().unwrap()),
508 url: None,
509 kind: None,
510 },
511 "foo@1.2",
512 );
513
514 ok(
516 "regex",
517 PackageIdSpec {
518 name: String::from("regex"),
519 version: None,
520 url: None,
521 kind: None,
522 },
523 "regex",
524 );
525 ok(
526 "regex@1.4",
527 PackageIdSpec {
528 name: String::from("regex"),
529 version: Some("1.4".parse().unwrap()),
530 url: None,
531 kind: None,
532 },
533 "regex@1.4",
534 );
535 ok(
536 "regex@1.4.3",
537 PackageIdSpec {
538 name: String::from("regex"),
539 version: Some("1.4.3".parse().unwrap()),
540 url: None,
541 kind: None,
542 },
543 "regex@1.4.3",
544 );
545 ok(
546 "https://github.com/rust-lang/crates.io-index#regex",
547 PackageIdSpec {
548 name: String::from("regex"),
549 version: None,
550 url: Some(Url::parse("https://github.com/rust-lang/crates.io-index").unwrap()),
551 kind: None,
552 },
553 "https://github.com/rust-lang/crates.io-index#regex",
554 );
555 ok(
556 "https://github.com/rust-lang/crates.io-index#regex@1.4.3",
557 PackageIdSpec {
558 name: String::from("regex"),
559 version: Some("1.4.3".parse().unwrap()),
560 url: Some(Url::parse("https://github.com/rust-lang/crates.io-index").unwrap()),
561 kind: None,
562 },
563 "https://github.com/rust-lang/crates.io-index#regex@1.4.3",
564 );
565 ok(
566 "sparse+https://github.com/rust-lang/crates.io-index#regex@1.4.3",
567 PackageIdSpec {
568 name: String::from("regex"),
569 version: Some("1.4.3".parse().unwrap()),
570 url: Some(
571 Url::parse("sparse+https://github.com/rust-lang/crates.io-index").unwrap(),
572 ),
573 kind: Some(SourceKind::SparseRegistry),
574 },
575 "sparse+https://github.com/rust-lang/crates.io-index#regex@1.4.3",
576 );
577 ok(
578 "https://github.com/rust-lang/cargo#0.52.0",
579 PackageIdSpec {
580 name: String::from("cargo"),
581 version: Some("0.52.0".parse().unwrap()),
582 url: Some(Url::parse("https://github.com/rust-lang/cargo").unwrap()),
583 kind: None,
584 },
585 "https://github.com/rust-lang/cargo#0.52.0",
586 );
587 ok(
588 "https://github.com/rust-lang/cargo#cargo-platform@0.1.2",
589 PackageIdSpec {
590 name: String::from("cargo-platform"),
591 version: Some("0.1.2".parse().unwrap()),
592 url: Some(Url::parse("https://github.com/rust-lang/cargo").unwrap()),
593 kind: None,
594 },
595 "https://github.com/rust-lang/cargo#cargo-platform@0.1.2",
596 );
597 ok(
598 "ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
599 PackageIdSpec {
600 name: String::from("regex"),
601 version: Some("1.4.3".parse().unwrap()),
602 url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
603 kind: None,
604 },
605 "ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
606 );
607 ok(
608 "git+ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
609 PackageIdSpec {
610 name: String::from("regex"),
611 version: Some("1.4.3".parse().unwrap()),
612 url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
613 kind: Some(SourceKind::Git(GitReference::DefaultBranch)),
614 },
615 "git+ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
616 );
617 ok(
618 "git+ssh://git@github.com/rust-lang/regex.git?branch=dev#regex@1.4.3",
619 PackageIdSpec {
620 name: String::from("regex"),
621 version: Some("1.4.3".parse().unwrap()),
622 url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
623 kind: Some(SourceKind::Git(GitReference::Branch("dev".to_owned()))),
624 },
625 "git+ssh://git@github.com/rust-lang/regex.git?branch=dev#regex@1.4.3",
626 );
627 ok(
628 "file:///path/to/my/project/foo",
629 PackageIdSpec {
630 name: String::from("foo"),
631 version: None,
632 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
633 kind: None,
634 },
635 "file:///path/to/my/project/foo",
636 );
637 ok(
638 "file:///path/to/my/project/foo::bar",
639 PackageIdSpec {
640 name: String::from("foo::bar"),
641 version: None,
642 url: Some(Url::parse("file:///path/to/my/project/foo::bar").unwrap()),
643 kind: None,
644 },
645 "file:///path/to/my/project/foo::bar",
646 );
647 ok(
648 "file:///path/to/my/project/foo#1.1.8",
649 PackageIdSpec {
650 name: String::from("foo"),
651 version: Some("1.1.8".parse().unwrap()),
652 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
653 kind: None,
654 },
655 "file:///path/to/my/project/foo#1.1.8",
656 );
657 ok(
658 "path+file:///path/to/my/project/foo#1.1.8",
659 PackageIdSpec {
660 name: String::from("foo"),
661 version: Some("1.1.8".parse().unwrap()),
662 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
663 kind: Some(SourceKind::Path),
664 },
665 "path+file:///path/to/my/project/foo#1.1.8",
666 );
667 ok(
668 "path+file:///path/to/my/project/foo#bar",
669 PackageIdSpec {
670 name: String::from("bar"),
671 version: None,
672 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
673 kind: Some(SourceKind::Path),
674 },
675 "path+file:///path/to/my/project/foo#bar",
676 );
677 ok(
678 "path+file:///path/to/my/project/foo#foo::bar",
679 PackageIdSpec {
680 name: String::from("foo::bar"),
681 version: None,
682 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
683 kind: Some(SourceKind::Path),
684 },
685 "path+file:///path/to/my/project/foo#foo::bar",
686 );
687 ok(
688 "path+file:///path/to/my/project/foo#bar:1.1.8",
689 PackageIdSpec {
690 name: String::from("bar"),
691 version: Some("1.1.8".parse().unwrap()),
692 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
693 kind: Some(SourceKind::Path),
694 },
695 "path+file:///path/to/my/project/foo#bar@1.1.8",
696 );
697 ok(
698 "path+file:///path/to/my/project/foo#foo::bar:1.1.8",
699 PackageIdSpec {
700 name: String::from("foo::bar"),
701 version: Some("1.1.8".parse().unwrap()),
702 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
703 kind: Some(SourceKind::Path),
704 },
705 "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
706 );
707 ok(
708 "path+file:///path/to/my/project/foo#bar@1.1.8",
709 PackageIdSpec {
710 name: String::from("bar"),
711 version: Some("1.1.8".parse().unwrap()),
712 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
713 kind: Some(SourceKind::Path),
714 },
715 "path+file:///path/to/my/project/foo#bar@1.1.8",
716 );
717 ok(
718 "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
719 PackageIdSpec {
720 name: String::from("foo::bar"),
721 version: Some("1.1.8".parse().unwrap()),
722 url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
723 kind: Some(SourceKind::Path),
724 },
725 "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
726 );
727 }
728
729 #[test]
730 fn bad_parsing() {
731 err!("baz:", ErrorKind::PartialVersion(_));
732 err!("baz:*", ErrorKind::PartialVersion(_));
733 err!("baz@", ErrorKind::PartialVersion(_));
734 err!("baz@*", ErrorKind::PartialVersion(_));
735 err!("baz@^1.0", ErrorKind::PartialVersion(_));
736 err!("https://baz:1.0", ErrorKind::NameValidation(_));
737 err!("https://#baz:1.0", ErrorKind::NameValidation(_));
738 err!(
739 "foobar+https://github.com/rust-lang/crates.io-index",
740 ErrorKind::UnsupportedProtocol(_)
741 );
742 err!(
743 "path+https://github.com/rust-lang/crates.io-index",
744 ErrorKind::UnsupportedPathPlusScheme(_)
745 );
746
747 err!(
749 "file:///path/to/my/project/foo?branch=dev",
750 ErrorKind::UnexpectedQueryString(_)
751 );
752 err!(
753 "path+file:///path/to/my/project/foo?branch=dev",
754 ErrorKind::UnexpectedQueryString(_)
755 );
756 err!(
757 "registry+https://github.com/rust-lang/cargo?branch=dev#0.52.0",
758 ErrorKind::UnexpectedQueryString(_)
759 );
760 err!(
761 "sparse+https://github.com/rust-lang/cargo?branch=dev#0.52.0",
762 ErrorKind::UnexpectedQueryString(_)
763 );
764 err!("@1.2.3", ErrorKind::NameValidation(_));
765 err!("registry+https://github.com", ErrorKind::NameValidation(_));
766 err!("https://crates.io/1foo#1.2.3", ErrorKind::NameValidation(_));
767 }
768}