Skip to main content

cargo_util_schemas/core/
package_id_spec.rs

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/// Some or all of the data required to identify a package:
17///
18///  1. the package name (a `String`, required)
19///  2. the package version (a `Version`, optional)
20///  3. the package source (a `Url`, optional)
21///
22/// If any of the optional fields are omitted, then the package ID may be ambiguous, there may be
23/// more than one package/version/url combo that will match. However, often just the name is
24/// sufficient to uniquely define a package ID.
25#[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    /// Parses a spec string and returns a `PackageIdSpec` if the string was valid.
59    ///
60    /// # Examples
61    /// Some examples of valid strings
62    ///
63    /// ```
64    /// use cargo_util_schemas::core::PackageIdSpec;
65    ///
66    /// let specs = vec![
67    ///     "foo",
68    ///     "foo@1.4",
69    ///     "foo@1.4.3",
70    ///     "foo:1.2.3",
71    ///     "https://github.com/rust-lang/crates.io-index#foo",
72    ///     "https://github.com/rust-lang/crates.io-index#foo@1.4.3",
73    ///     "ssh://git@github.com/rust-lang/foo.git#foo@1.4.3",
74    ///     "file:///path/to/my/project/foo",
75    ///     "file:///path/to/my/project/foo#1.1.8",
76    ///     "builtin://.#core"
77    /// ];
78    /// for spec in specs {
79    ///     assert!(PackageIdSpec::parse(spec).is_ok());
80    /// }
81    pub fn parse(spec: &str) -> Result<PackageIdSpec> {
82        if spec.contains("://") {
83            if let Ok(url) = Url::parse(spec) {
84                return PackageIdSpec::from_url(url);
85            }
86        } else if spec.contains('/') || spec.contains('\\') {
87            // Diagnostic only; `Url::from_file_path` doesn't exist on bare wasm.
88            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
89            {
90                let abs = std::env::current_dir().unwrap_or_default().join(spec);
91                if abs.exists() {
92                    let maybe_url = Url::from_file_path(abs)
93                        .map_or_else(|_| "a file:// URL".to_string(), |url| url.to_string());
94                    return Err(ErrorKind::MaybeFilePath {
95                        spec: spec.into(),
96                        maybe_url,
97                    }
98                    .into());
99                }
100            }
101        }
102        let (name, version) = match parse_spec(spec)? {
103            Some((name, ver)) => (name, Some(ver)),
104            None => (spec.to_owned(), None),
105        };
106        PackageName::new(&name)?;
107        Ok(PackageIdSpec {
108            name: String::from(name),
109            version,
110            url: None,
111            kind: None,
112        })
113    }
114
115    /// Tries to convert a valid `Url` to a `PackageIdSpec`.
116    fn from_url(mut url: Url) -> Result<PackageIdSpec> {
117        let mut kind = None;
118        if let Some((kind_str, scheme)) = url.scheme().split_once('+') {
119            match kind_str {
120                "git" => {
121                    let git_ref = GitReference::from_query(url.query_pairs());
122                    url.set_query(None);
123                    kind = Some(SourceKind::Git(git_ref));
124                    url = strip_url_protocol(&url)?;
125                }
126                "registry" => {
127                    if url.query().is_some() {
128                        return Err(ErrorKind::UnexpectedQueryString(url).into());
129                    }
130                    kind = Some(SourceKind::Registry);
131                    url = strip_url_protocol(&url)?;
132                }
133                "sparse" => {
134                    if url.query().is_some() {
135                        return Err(ErrorKind::UnexpectedQueryString(url).into());
136                    }
137                    kind = Some(SourceKind::SparseRegistry);
138                    // Leave `sparse` as part of URL, see `SourceId::new`
139                    // url = strip_url_protocol(&url);
140                }
141                "path" => {
142                    if url.query().is_some() {
143                        return Err(ErrorKind::UnexpectedQueryString(url).into());
144                    }
145                    if scheme != "file" {
146                        return Err(ErrorKind::UnsupportedPathPlusScheme(scheme.into()).into());
147                    }
148                    kind = Some(SourceKind::Path);
149                    url = strip_url_protocol(&url)?;
150                }
151                "builtin" => {
152                    if url.query().is_some() {
153                        return Err(ErrorKind::UnexpectedQueryString(url).into());
154                    }
155                    if scheme != "builtin" {
156                        return Err(ErrorKind::UnsupportedBuiltinScheme(scheme.into()).into());
157                    }
158                    kind = Some(SourceKind::Builtin);
159                    url = strip_url_protocol(&url)?;
160                }
161                kind => return Err(ErrorKind::UnsupportedProtocol(kind.into()).into()),
162            }
163        } else if url.scheme() == "builtin" {
164            if url.query().is_some() {
165                return Err(ErrorKind::UnexpectedQueryString(url).into());
166            }
167            kind = Some(SourceKind::Builtin)
168        } else if url.query().is_some() {
169            return Err(ErrorKind::UnexpectedQueryString(url).into());
170        }
171
172        let frag = url.fragment().map(|s| s.to_owned());
173        url.set_fragment(None);
174
175        if kind == Some(SourceKind::Builtin) {
176            // Builtins are different in that they require a fragment and cannot have path segments,
177            // so handle them early
178            if url.host_str() != Some(".") {
179                return Err(ErrorKind::InvalidHostname.into());
180            }
181            if url.path() != "" {
182                return Err(ErrorKind::InvalidPath.into());
183            }
184            let Some(frag) = frag else {
185                return Err(ErrorKind::FragmentRequired.into());
186            };
187
188            if frag.is_empty() {
189                return Err(ErrorKind::EmptyFragment.into());
190            }
191            let name = match parse_spec(&frag)? {
192                Some((name, ver)) => {
193                    if !ver.matches(&Version::new(0, 0, 0)) {
194                        return Err(ErrorKind::InvalidVersion(ver.to_string()).into());
195                    }
196                    name
197                }
198                None => frag,
199            };
200
201            PackageName::new(&name)?;
202            let version = PartialVersion {
203                major: 0,
204                minor: Some(0),
205                patch: Some(0),
206                pre: None,
207                build: None,
208            };
209            return Ok(PackageIdSpec {
210                name,
211                version: Some(version),
212                url: Some(url),
213                kind,
214            });
215        }
216
217        let (name, version) = {
218            let Some(path_name) = url.path_segments().and_then(|mut p| p.next_back()) else {
219                return Err(ErrorKind::MissingUrlPath(url).into());
220            };
221            match frag {
222                Some(fragment) => match parse_spec(&fragment)? {
223                    Some((name, ver)) => (name, Some(ver)),
224                    None => {
225                        let Some(f) = fragment.chars().next() else {
226                            return Err(PackageIdSpecError(ErrorKind::EmptyFragment));
227                        };
228
229                        if f.is_alphabetic() {
230                            (String::from(fragment.as_str()), None)
231                        } else {
232                            let version = fragment.parse::<PartialVersion>()?;
233                            (String::from(path_name), Some(version))
234                        }
235                    }
236                },
237                None => (String::from(path_name), None),
238            }
239        };
240        PackageName::new(&name)?;
241        Ok(PackageIdSpec {
242            name,
243            version,
244            url: Some(url),
245            kind,
246        })
247    }
248
249    pub fn name(&self) -> &str {
250        self.name.as_str()
251    }
252
253    /// Full `semver::Version`, if present
254    pub fn version(&self) -> Option<Version> {
255        self.version.as_ref().and_then(|v| v.to_version())
256    }
257
258    pub fn partial_version(&self) -> Option<&PartialVersion> {
259        self.version.as_ref()
260    }
261
262    pub fn url(&self) -> Option<&Url> {
263        self.url.as_ref()
264    }
265
266    pub fn set_url(&mut self, url: Url) {
267        self.url = Some(url);
268    }
269
270    pub fn kind(&self) -> Option<&SourceKind> {
271        self.kind.as_ref()
272    }
273
274    pub fn set_kind(&mut self, kind: SourceKind) {
275        self.kind = Some(kind);
276    }
277}
278
279fn parse_spec(spec: &str) -> Result<Option<(String, PartialVersion)>> {
280    let Some((name, ver)) = spec
281        .rsplit_once('@')
282        .or_else(|| spec.rsplit_once(':').filter(|(n, _)| !n.ends_with(':')))
283    else {
284        return Ok(None);
285    };
286    let name = name.to_owned();
287    let ver = ver.parse::<PartialVersion>()?;
288    Ok(Some((name, ver)))
289}
290
291fn strip_url_protocol(url: &Url) -> Result<Url> {
292    // Ridiculous hoop because `Url::set_scheme` errors when changing to http/https
293    let raw = url.to_string();
294    let (_, rest) = raw
295        .split_once('+')
296        .ok_or_else(|| ErrorKind::InvalidPkgIdUrl(raw.clone()))?;
297    rest.parse()
298        .map_err(|_| ErrorKind::InvalidPkgIdUrl(raw).into())
299}
300
301impl fmt::Display for PackageIdSpec {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        let mut printed_name = false;
304        match self.url {
305            Some(ref url) => {
306                if let Some(protocol) = self.kind.as_ref().and_then(SourceKind::protocol) {
307                    write!(f, "{protocol}+")?;
308                }
309                write!(f, "{}", url)?;
310                if let Some(SourceKind::Git(git_ref)) = self.kind.as_ref() {
311                    if let Some(pretty) = git_ref.pretty_ref(true) {
312                        write!(f, "?{}", pretty)?;
313                    }
314                }
315                if self.kind() == Some(&SourceKind::Builtin)
316                    || url.path_segments().unwrap().next_back().unwrap() != &*self.name
317                {
318                    printed_name = true;
319                    write!(f, "#{}", self.name)?;
320                }
321            }
322            None => {
323                printed_name = true;
324                write!(f, "{}", self.name)?;
325            }
326        }
327        if let Some(ref v) = self.version
328            && self.kind != Some(SourceKind::Builtin)
329        {
330            write!(f, "{}{}", if printed_name { "@" } else { "#" }, v)?;
331        }
332        Ok(())
333    }
334}
335
336impl ser::Serialize for PackageIdSpec {
337    fn serialize<S>(&self, s: S) -> std::result::Result<S::Ok, S::Error>
338    where
339        S: ser::Serializer,
340    {
341        self.to_string().serialize(s)
342    }
343}
344
345impl<'de> de::Deserialize<'de> for PackageIdSpec {
346    fn deserialize<D>(d: D) -> std::result::Result<PackageIdSpec, D::Error>
347    where
348        D: de::Deserializer<'de>,
349    {
350        let string = String::deserialize(d)?;
351        PackageIdSpec::parse(&string).map_err(de::Error::custom)
352    }
353}
354
355#[cfg(feature = "unstable-schema")]
356impl schemars::JsonSchema for PackageIdSpec {
357    fn schema_name() -> std::borrow::Cow<'static, str> {
358        "PackageIdSpec".into()
359    }
360    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
361        <String as schemars::JsonSchema>::json_schema(generator)
362    }
363}
364
365#[derive(Debug, thiserror::Error)]
366#[error(transparent)]
367pub struct PackageIdSpecError(#[from] ErrorKind);
368
369impl From<PartialVersionError> for PackageIdSpecError {
370    fn from(value: PartialVersionError) -> Self {
371        ErrorKind::PartialVersion(value).into()
372    }
373}
374
375impl From<NameValidationError> for PackageIdSpecError {
376    fn from(value: NameValidationError) -> Self {
377        ErrorKind::NameValidation(value).into()
378    }
379}
380
381/// Non-public error kind for [`PackageIdSpecError`].
382#[non_exhaustive]
383#[derive(Debug, thiserror::Error)]
384enum ErrorKind {
385    #[error("unsupported source protocol: {0}")]
386    UnsupportedProtocol(String),
387
388    #[error("`path+{0}` is unsupported; `path+file` and `file` schemes are supported")]
389    UnsupportedPathPlusScheme(String),
390
391    #[error("cannot have a query string in a pkgid: {0}")]
392    UnexpectedQueryString(Url),
393
394    #[error("pkgid urls must have at least one path component: {0}")]
395    MissingUrlPath(Url),
396
397    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
398    #[error("package ID specification `{spec}` looks like a file path, maybe try {maybe_url}")]
399    MaybeFilePath { spec: String, maybe_url: String },
400
401    #[error("pkgid url cannot have an empty fragment")]
402    EmptyFragment,
403
404    #[error("builtin package ID specifications must specify the package name in the fragment")]
405    FragmentRequired,
406
407    #[error("version `{0}` is invalid for builtin packages, which are unversioned (or `0.0.0`)")]
408    InvalidVersion(String),
409
410    #[error("only `.` is permitted as a hostname for builtin package ID specifications")]
411    InvalidHostname,
412
413    #[error("only empty paths are permitted for builtin package ID specifications")]
414    InvalidPath,
415
416    #[error(
417        "`builtin+{0}` is unsupported - only the `builtin` protocol is supported for `builtin` kinds"
418    )]
419    UnsupportedBuiltinScheme(String),
420
421    #[error(transparent)]
422    NameValidation(#[from] crate::restricted_names::NameValidationError),
423
424    #[error(transparent)]
425    PartialVersion(#[from] crate::core::PartialVersionError),
426
427    #[error("invalid pkgid url: {0}")]
428    InvalidPkgIdUrl(String),
429}
430
431#[cfg(test)]
432mod tests {
433    use super::ErrorKind;
434    use super::PackageIdSpec;
435    use crate::core::{GitReference, SourceKind};
436    use url::Url;
437
438    #[track_caller]
439    fn ok(spec: &str, expected: PackageIdSpec, expected_rendered: &str) {
440        let parsed = PackageIdSpec::parse(spec).unwrap();
441        assert_eq!(parsed, expected);
442        let rendered = parsed.to_string();
443        assert_eq!(rendered, expected_rendered);
444        let reparsed = PackageIdSpec::parse(&rendered).unwrap();
445        assert_eq!(reparsed, expected);
446    }
447
448    macro_rules! err {
449        ($spec:expr, $expected:pat) => {
450            let err = PackageIdSpec::parse($spec).unwrap_err();
451            let kind = err.0;
452            assert!(
453                matches!(kind, $expected),
454                "`{}` parse error mismatch, got {kind:?}",
455                $spec
456            );
457        };
458    }
459
460    #[test]
461    fn good_parsing() {
462        ok(
463            "https://crates.io/foo",
464            PackageIdSpec {
465                name: String::from("foo"),
466                version: None,
467                url: Some(Url::parse("https://crates.io/foo").unwrap()),
468                kind: None,
469            },
470            "https://crates.io/foo",
471        );
472        ok(
473            "https://crates.io/foo#1.2.3",
474            PackageIdSpec {
475                name: String::from("foo"),
476                version: Some("1.2.3".parse().unwrap()),
477                url: Some(Url::parse("https://crates.io/foo").unwrap()),
478                kind: None,
479            },
480            "https://crates.io/foo#1.2.3",
481        );
482        ok(
483            "https://crates.io/foo#1.2",
484            PackageIdSpec {
485                name: String::from("foo"),
486                version: Some("1.2".parse().unwrap()),
487                url: Some(Url::parse("https://crates.io/foo").unwrap()),
488                kind: None,
489            },
490            "https://crates.io/foo#1.2",
491        );
492        ok(
493            "https://crates.io/foo#bar:1.2.3",
494            PackageIdSpec {
495                name: String::from("bar"),
496                version: Some("1.2.3".parse().unwrap()),
497                url: Some(Url::parse("https://crates.io/foo").unwrap()),
498                kind: None,
499            },
500            "https://crates.io/foo#bar@1.2.3",
501        );
502        ok(
503            "https://crates.io/foo#bar@1.2.3",
504            PackageIdSpec {
505                name: String::from("bar"),
506                version: Some("1.2.3".parse().unwrap()),
507                url: Some(Url::parse("https://crates.io/foo").unwrap()),
508                kind: None,
509            },
510            "https://crates.io/foo#bar@1.2.3",
511        );
512        ok(
513            "https://crates.io/foo#bar@1.2",
514            PackageIdSpec {
515                name: String::from("bar"),
516                version: Some("1.2".parse().unwrap()),
517                url: Some(Url::parse("https://crates.io/foo").unwrap()),
518                kind: None,
519            },
520            "https://crates.io/foo#bar@1.2",
521        );
522        ok(
523            "registry+https://crates.io/foo#bar@1.2",
524            PackageIdSpec {
525                name: String::from("bar"),
526                version: Some("1.2".parse().unwrap()),
527                url: Some(Url::parse("https://crates.io/foo").unwrap()),
528                kind: Some(SourceKind::Registry),
529            },
530            "registry+https://crates.io/foo#bar@1.2",
531        );
532        ok(
533            "sparse+https://crates.io/foo#bar@1.2",
534            PackageIdSpec {
535                name: String::from("bar"),
536                version: Some("1.2".parse().unwrap()),
537                url: Some(Url::parse("sparse+https://crates.io/foo").unwrap()),
538                kind: Some(SourceKind::SparseRegistry),
539            },
540            "sparse+https://crates.io/foo#bar@1.2",
541        );
542        ok(
543            "foo",
544            PackageIdSpec {
545                name: String::from("foo"),
546                version: None,
547                url: None,
548                kind: None,
549            },
550            "foo",
551        );
552        ok(
553            "foo::bar",
554            PackageIdSpec {
555                name: String::from("foo::bar"),
556                version: None,
557                url: None,
558                kind: None,
559            },
560            "foo::bar",
561        );
562        ok(
563            "foo:1.2.3",
564            PackageIdSpec {
565                name: String::from("foo"),
566                version: Some("1.2.3".parse().unwrap()),
567                url: None,
568                kind: None,
569            },
570            "foo@1.2.3",
571        );
572        ok(
573            "foo::bar:1.2.3",
574            PackageIdSpec {
575                name: String::from("foo::bar"),
576                version: Some("1.2.3".parse().unwrap()),
577                url: None,
578                kind: None,
579            },
580            "foo::bar@1.2.3",
581        );
582        ok(
583            "foo@1.2.3",
584            PackageIdSpec {
585                name: String::from("foo"),
586                version: Some("1.2.3".parse().unwrap()),
587                url: None,
588                kind: None,
589            },
590            "foo@1.2.3",
591        );
592        ok(
593            "foo::bar@1.2.3",
594            PackageIdSpec {
595                name: String::from("foo::bar"),
596                version: Some("1.2.3".parse().unwrap()),
597                url: None,
598                kind: None,
599            },
600            "foo::bar@1.2.3",
601        );
602        ok(
603            "foo@1.2",
604            PackageIdSpec {
605                name: String::from("foo"),
606                version: Some("1.2".parse().unwrap()),
607                url: None,
608                kind: None,
609            },
610            "foo@1.2",
611        );
612
613        // pkgid-spec.md
614        ok(
615            "regex",
616            PackageIdSpec {
617                name: String::from("regex"),
618                version: None,
619                url: None,
620                kind: None,
621            },
622            "regex",
623        );
624        ok(
625            "regex@1.4",
626            PackageIdSpec {
627                name: String::from("regex"),
628                version: Some("1.4".parse().unwrap()),
629                url: None,
630                kind: None,
631            },
632            "regex@1.4",
633        );
634        ok(
635            "regex@1.4.3",
636            PackageIdSpec {
637                name: String::from("regex"),
638                version: Some("1.4.3".parse().unwrap()),
639                url: None,
640                kind: None,
641            },
642            "regex@1.4.3",
643        );
644        ok(
645            "https://github.com/rust-lang/crates.io-index#regex",
646            PackageIdSpec {
647                name: String::from("regex"),
648                version: None,
649                url: Some(Url::parse("https://github.com/rust-lang/crates.io-index").unwrap()),
650                kind: None,
651            },
652            "https://github.com/rust-lang/crates.io-index#regex",
653        );
654        ok(
655            "https://github.com/rust-lang/crates.io-index#regex@1.4.3",
656            PackageIdSpec {
657                name: String::from("regex"),
658                version: Some("1.4.3".parse().unwrap()),
659                url: Some(Url::parse("https://github.com/rust-lang/crates.io-index").unwrap()),
660                kind: None,
661            },
662            "https://github.com/rust-lang/crates.io-index#regex@1.4.3",
663        );
664        ok(
665            "sparse+https://github.com/rust-lang/crates.io-index#regex@1.4.3",
666            PackageIdSpec {
667                name: String::from("regex"),
668                version: Some("1.4.3".parse().unwrap()),
669                url: Some(
670                    Url::parse("sparse+https://github.com/rust-lang/crates.io-index").unwrap(),
671                ),
672                kind: Some(SourceKind::SparseRegistry),
673            },
674            "sparse+https://github.com/rust-lang/crates.io-index#regex@1.4.3",
675        );
676        ok(
677            "https://github.com/rust-lang/cargo#0.52.0",
678            PackageIdSpec {
679                name: String::from("cargo"),
680                version: Some("0.52.0".parse().unwrap()),
681                url: Some(Url::parse("https://github.com/rust-lang/cargo").unwrap()),
682                kind: None,
683            },
684            "https://github.com/rust-lang/cargo#0.52.0",
685        );
686        ok(
687            "https://github.com/rust-lang/cargo#cargo-platform@0.1.2",
688            PackageIdSpec {
689                name: String::from("cargo-platform"),
690                version: Some("0.1.2".parse().unwrap()),
691                url: Some(Url::parse("https://github.com/rust-lang/cargo").unwrap()),
692                kind: None,
693            },
694            "https://github.com/rust-lang/cargo#cargo-platform@0.1.2",
695        );
696        ok(
697            "ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
698            PackageIdSpec {
699                name: String::from("regex"),
700                version: Some("1.4.3".parse().unwrap()),
701                url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
702                kind: None,
703            },
704            "ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
705        );
706        ok(
707            "git+ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
708            PackageIdSpec {
709                name: String::from("regex"),
710                version: Some("1.4.3".parse().unwrap()),
711                url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
712                kind: Some(SourceKind::Git(GitReference::DefaultBranch)),
713            },
714            "git+ssh://git@github.com/rust-lang/regex.git#regex@1.4.3",
715        );
716        ok(
717            "git+ssh://git@github.com/rust-lang/regex.git?branch=dev#regex@1.4.3",
718            PackageIdSpec {
719                name: String::from("regex"),
720                version: Some("1.4.3".parse().unwrap()),
721                url: Some(Url::parse("ssh://git@github.com/rust-lang/regex.git").unwrap()),
722                kind: Some(SourceKind::Git(GitReference::Branch("dev".to_owned()))),
723            },
724            "git+ssh://git@github.com/rust-lang/regex.git?branch=dev#regex@1.4.3",
725        );
726        ok(
727            "file:///path/to/my/project/foo",
728            PackageIdSpec {
729                name: String::from("foo"),
730                version: None,
731                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
732                kind: None,
733            },
734            "file:///path/to/my/project/foo",
735        );
736        ok(
737            "file:///path/to/my/project/foo::bar",
738            PackageIdSpec {
739                name: String::from("foo::bar"),
740                version: None,
741                url: Some(Url::parse("file:///path/to/my/project/foo::bar").unwrap()),
742                kind: None,
743            },
744            "file:///path/to/my/project/foo::bar",
745        );
746        ok(
747            "file:///path/to/my/project/foo#1.1.8",
748            PackageIdSpec {
749                name: String::from("foo"),
750                version: Some("1.1.8".parse().unwrap()),
751                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
752                kind: None,
753            },
754            "file:///path/to/my/project/foo#1.1.8",
755        );
756        ok(
757            "path+file:///path/to/my/project/foo#1.1.8",
758            PackageIdSpec {
759                name: String::from("foo"),
760                version: Some("1.1.8".parse().unwrap()),
761                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
762                kind: Some(SourceKind::Path),
763            },
764            "path+file:///path/to/my/project/foo#1.1.8",
765        );
766        ok(
767            "path+file:///path/to/my/project/foo#bar",
768            PackageIdSpec {
769                name: String::from("bar"),
770                version: None,
771                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
772                kind: Some(SourceKind::Path),
773            },
774            "path+file:///path/to/my/project/foo#bar",
775        );
776        ok(
777            "path+file:///path/to/my/project/foo#foo::bar",
778            PackageIdSpec {
779                name: String::from("foo::bar"),
780                version: None,
781                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
782                kind: Some(SourceKind::Path),
783            },
784            "path+file:///path/to/my/project/foo#foo::bar",
785        );
786        ok(
787            "path+file:///path/to/my/project/foo#bar:1.1.8",
788            PackageIdSpec {
789                name: String::from("bar"),
790                version: Some("1.1.8".parse().unwrap()),
791                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
792                kind: Some(SourceKind::Path),
793            },
794            "path+file:///path/to/my/project/foo#bar@1.1.8",
795        );
796        ok(
797            "path+file:///path/to/my/project/foo#foo::bar:1.1.8",
798            PackageIdSpec {
799                name: String::from("foo::bar"),
800                version: Some("1.1.8".parse().unwrap()),
801                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
802                kind: Some(SourceKind::Path),
803            },
804            "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
805        );
806        ok(
807            "path+file:///path/to/my/project/foo#bar@1.1.8",
808            PackageIdSpec {
809                name: String::from("bar"),
810                version: Some("1.1.8".parse().unwrap()),
811                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
812                kind: Some(SourceKind::Path),
813            },
814            "path+file:///path/to/my/project/foo#bar@1.1.8",
815        );
816        ok(
817            "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
818            PackageIdSpec {
819                name: String::from("foo::bar"),
820                version: Some("1.1.8".parse().unwrap()),
821                url: Some(Url::parse("file:///path/to/my/project/foo").unwrap()),
822                kind: Some(SourceKind::Path),
823            },
824            "path+file:///path/to/my/project/foo#foo::bar@1.1.8",
825        );
826        ok(
827            "builtin://.#core",
828            PackageIdSpec {
829                name: String::from("core"),
830                version: Some("0.0.0".parse().unwrap()),
831                url: Some(Url::parse("builtin://.").unwrap()),
832                kind: Some(SourceKind::Builtin),
833            },
834            "builtin://.#core",
835        );
836        ok(
837            "builtin+builtin://.#core",
838            PackageIdSpec {
839                name: String::from("core"),
840                version: Some("0.0.0".parse().unwrap()),
841                url: Some(Url::parse("builtin://.").unwrap()),
842                kind: Some(SourceKind::Builtin),
843            },
844            "builtin://.#core",
845        );
846        ok(
847            "builtin+builtin://.#core@0.0.0",
848            PackageIdSpec {
849                name: String::from("core"),
850                version: Some("0.0.0".parse().unwrap()),
851                url: Some(Url::parse("builtin://.").unwrap()),
852                kind: Some(SourceKind::Builtin),
853            },
854            "builtin://.#core",
855        );
856        ok(
857            "builtin+builtin://.#core@0",
858            PackageIdSpec {
859                name: String::from("core"),
860                version: Some("0.0.0".parse().unwrap()),
861                url: Some(Url::parse("builtin://.").unwrap()),
862                kind: Some(SourceKind::Builtin),
863            },
864            "builtin://.#core",
865        );
866    }
867
868    #[test]
869    fn bad_parsing() {
870        err!("baz:", ErrorKind::PartialVersion(_));
871        err!("baz:*", ErrorKind::PartialVersion(_));
872        err!("baz@", ErrorKind::PartialVersion(_));
873        err!("baz@*", ErrorKind::PartialVersion(_));
874        err!("baz@^1.0", ErrorKind::PartialVersion(_));
875        err!("https://baz:1.0", ErrorKind::NameValidation(_));
876        err!("https://#baz:1.0", ErrorKind::NameValidation(_));
877        err!(
878            "foobar+https://github.com/rust-lang/crates.io-index",
879            ErrorKind::UnsupportedProtocol(_)
880        );
881        err!(
882            "path+https://github.com/rust-lang/crates.io-index",
883            ErrorKind::UnsupportedPathPlusScheme(_)
884        );
885
886        // Only `git+` can use `?`
887        err!(
888            "file:///path/to/my/project/foo?branch=dev",
889            ErrorKind::UnexpectedQueryString(_)
890        );
891        err!(
892            "path+file:///path/to/my/project/foo?branch=dev",
893            ErrorKind::UnexpectedQueryString(_)
894        );
895        err!(
896            "registry+https://github.com/rust-lang/cargo?branch=dev#0.52.0",
897            ErrorKind::UnexpectedQueryString(_)
898        );
899        err!(
900            "sparse+https://github.com/rust-lang/cargo?branch=dev#0.52.0",
901            ErrorKind::UnexpectedQueryString(_)
902        );
903        err!("@1.2.3", ErrorKind::NameValidation(_));
904        err!("registry+https://github.com", ErrorKind::NameValidation(_));
905        err!("https://crates.io/1foo#1.2.3", ErrorKind::NameValidation(_));
906        err!("https://example.com/foo#", ErrorKind::EmptyFragment);
907        err!("git++://x:", ErrorKind::InvalidPkgIdUrl(_));
908        err!(
909            "git++https://github.com/rust-lang/cargo",
910            ErrorKind::InvalidPkgIdUrl(_)
911        );
912        err!("git+https://", ErrorKind::InvalidPkgIdUrl(_));
913        err!("builtin://.", ErrorKind::FragmentRequired);
914        err!(
915            "builtin+builtin://.#core@0.1.0",
916            ErrorKind::InvalidVersion(_)
917        );
918        err!("builtin://.#", ErrorKind::EmptyFragment);
919        err!("builtin://.#0.0.0", ErrorKind::NameValidation(_));
920        err!("builtin://wrong#core", ErrorKind::InvalidHostname);
921        err!("builtin://./invalid/path#core", ErrorKind::InvalidPath);
922        err!(
923            "builtin+invalid://.",
924            ErrorKind::UnsupportedBuiltinScheme(_)
925        );
926        err!(
927            "builtin://.?query=test#core",
928            ErrorKind::UnexpectedQueryString(_)
929        );
930        err!(
931            "builtin+builtin://.?query=test#core",
932            ErrorKind::UnexpectedQueryString(_)
933        );
934    }
935}