krates 0.21.0

Create graphs of crates gathered from cargo metadata
Documentation
use crate::Error;
use semver::Version;

/// A package specification. See
/// [cargo pkgid](https://doc.rust-lang.org/cargo/commands/cargo-pkgid.html)
/// for more information on this.
#[derive(Debug, Clone)]
pub struct PkgSpec {
    pub name: String,
    pub version: Option<Version>,
    pub url: Option<String>,
}

impl PkgSpec {
    pub fn matches(&self, krate: &crate::cm::Package) -> bool {
        if self.name != krate.name {
            return false;
        }

        if let Some(vers) = &self.version {
            if vers != &krate.version {
                return false;
            }
        }

        let Some((url, src)) = self
            .url
            .as_ref()
            .zip(krate.source.as_ref().map(|s| s.repr.as_str()))
        else {
            return true;
        };

        let begin = src.find('+').map_or(0, |i| i + 1);
        let end = src.find('?').or_else(|| src.find('#')).unwrap_or(src.len());

        url == &src[begin..end]
    }
}

impl std::str::FromStr for PkgSpec {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let name_and_or_version = |nv: Option<&str>,
                                   url: Option<&str>|
         -> Result<(String, Option<Version>), Self::Err> {
            let validate_name = |n: &str| -> Result<String, Self::Err> {
                if n.find(|c: char| c != '-' && c != '_' && !c.is_ascii_alphanumeric())
                    .is_some()
                {
                    Err(Error::InvalidPkgSpec(
                        "found an invalid character for the package name",
                    ))
                } else {
                    Ok(n.to_owned())
                }
            };

            // We validate the url portion regardless, unlike cargo
            let path_name = match url {
                Some(url) => {
                    // Cargo is actually more lenient than this and will allow an end slash,
                    // even though that means it will never actually match a package due to
                    // how it retrieves a name from the url if the name isn't explicitly provided
                    if url.ends_with('/') {
                        return Err(Error::InvalidPkgSpec("url ends with /"));
                    }

                    match url.rfind("://") {
                        Some(ind) => match url.rfind('/') {
                            Some(pind) => {
                                if pind == ind + 2 {
                                    return Err(Error::InvalidPkgSpec("path required for urls"));
                                }

                                let path = &url[pind + 1..];
                                Some(path)
                            }
                            None => return Err(Error::InvalidPkgSpec("path required for urls")),
                        },
                        None => return Err(Error::InvalidPkgSpec("missing url scheme")),
                    }
                }
                None => None,
            };

            match nv {
                Some(nv) => {
                    match nv.find([':', '@']) {
                        Some(ind) => {
                            if ind == nv.len() - 1 {
                                return Err(Error::InvalidPkgSpec(
                                    "package spec cannot end with ':'",
                                ));
                            }

                            Ok((
                                validate_name(&nv[..ind])?,
                                Some(Version::parse(&nv[ind + 1..]).map_err(|_e| {
                                    Error::InvalidPkgSpec("failed to parse version")
                                })?),
                            ))
                        }
                        None => {
                            // If we have a url, this could be either a name and/or a version
                            match path_name {
                                Some(name) => {
                                    // This is the same way that cargo itself parses
                                    if nv.chars().next().unwrap().is_alphabetic() {
                                        Ok((validate_name(nv)?, None))
                                    } else {
                                        let version = Version::parse(nv).map_err(|_e| {
                                            Error::InvalidPkgSpec("failed to parse version")
                                        })?;

                                        Ok((validate_name(name)?, Some(version)))
                                    }
                                }
                                None => Ok((validate_name(nv)?, None)),
                            }
                        }
                    }
                }
                None => Ok((validate_name(path_name.unwrap())?, None)),
            }
        };

        if s.contains('/') {
            let url = if s.contains("://") {
                std::borrow::Cow::Borrowed(s)
            } else {
                std::borrow::Cow::Owned(format!("cargo://{}", s))
            };

            if let Some(ind) = url.find('#') {
                if ind == url.len() - 1 {
                    return Err(Error::InvalidPkgSpec("package spec cannot end with '#'"));
                }

                let url_no_frag = url[..ind].to_owned();

                let (name, version) =
                    name_and_or_version(Some(&url[ind + 1..]), Some(&url_no_frag))?;

                Ok(Self {
                    url: Some(url_no_frag),
                    name,
                    version,
                })
            } else {
                let (name, version) = name_and_or_version(None, Some(&url))?;

                Ok(Self {
                    url: Some(url.into_owned()),
                    name,
                    version,
                })
            }
        } else {
            let (name, version) = name_and_or_version(Some(s), None)?;

            Ok(Self {
                url: None,
                name,
                version,
            })
        }
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn name() {
        let spec: PkgSpec = "bitflags".parse().unwrap();

        assert_eq!("bitflags", spec.name);
        assert!(spec.version.is_none());
        assert!(spec.url.is_none());
    }

    #[test]
    fn name_and_version() {
        for spec in ["bitflags:1.0.4", "bitflags@1.0.4"] {
            let spec: PkgSpec = spec.parse().unwrap();

            assert_eq!("bitflags", spec.name);
            assert_eq!(Version::parse("1.0.4").unwrap(), spec.version.unwrap());
            assert!(spec.url.is_none());
        }
    }

    #[test]
    fn url() {
        let spec: PkgSpec = "https://github.com/rust-lang/cargo".parse().unwrap();

        assert_eq!("cargo", spec.name);
        assert!(spec.version.is_none());
        assert_eq!("https://github.com/rust-lang/cargo", spec.url.unwrap());
    }

    #[test]
    fn url_and_version() {
        let spec: PkgSpec = "https://github.com/rust-lang/cargo#0.33.0".parse().unwrap();

        assert_eq!("cargo", spec.name);
        assert_eq!(Version::parse("0.33.0").unwrap(), spec.version.unwrap());
        assert_eq!("https://github.com/rust-lang/cargo", spec.url.unwrap());
    }

    #[test]
    fn url_and_name() {
        let spec: PkgSpec = "https://github.com/rust-lang/crates.io-index#bitflags"
            .parse()
            .unwrap();

        assert_eq!("bitflags", spec.name);
        assert!(spec.version.is_none());
        assert_eq!(
            "https://github.com/rust-lang/crates.io-index",
            spec.url.unwrap()
        );
    }

    #[test]
    fn url_and_name_and_version() {
        for spec in [
            "https://github.com/rust-lang/cargo#crates-io:0.21.0",
            "https://github.com/rust-lang/cargo#crates-io@0.21.0",
        ] {
            let spec: PkgSpec = spec.parse().unwrap();

            assert_eq!("crates-io", spec.name);
            assert_eq!(Version::parse("0.21.0").unwrap(), spec.version.unwrap());
            assert_eq!("https://github.com/rust-lang/cargo", spec.url.unwrap());
        }
    }

    #[test]
    fn no_proto() {
        let spec: PkgSpec = "crates.io/foo".parse().unwrap();

        assert_eq!("foo", spec.name);
        assert!(spec.version.is_none());
        assert_eq!("cargo://crates.io/foo", spec.url.unwrap());
    }

    #[test]
    fn no_proto_and_version() {
        let spec: PkgSpec = "crates.io/foo#1.2.3".parse().unwrap();

        assert_eq!("foo", spec.name);
        assert_eq!(Version::parse("1.2.3").unwrap(), spec.version.unwrap());
        assert_eq!("cargo://crates.io/foo", spec.url.unwrap());
    }

    #[test]
    fn no_proto_and_name_and_version() {
        let spec: PkgSpec = "crates.io/foo#1.2.3".parse().unwrap();

        assert_eq!("foo", spec.name);
        assert_eq!(Version::parse("1.2.3").unwrap(), spec.version.unwrap());
        assert_eq!("cargo://crates.io/foo", spec.url.unwrap());
    }

    #[test]
    fn disallow_no_path() {
        let nopes = [
            "https://crates.io#1.2.3",
            "https://crates.io",
            "https://crates.io#foo",
        ];

        for nope in &nopes {
            match nope.parse::<PkgSpec>().unwrap_err() {
                Error::InvalidPkgSpec(err) => assert_eq!(err, "path required for urls"),
                nope => panic!("didn't expect {:?}", nope),
            }
        }

        for nope in &["https://crates.io/", "crates.io/#1.2.3"] {
            match nope.parse::<PkgSpec>().unwrap_err() {
                Error::InvalidPkgSpec(err) => assert_eq!(err, "url ends with /"),
                nope => panic!("didn't expect {:?}", nope),
            }
        }

        for nope in &["crates.io#foo", "crates.io#1.2.3"] {
            match nope.parse::<PkgSpec>().unwrap_err() {
                Error::InvalidPkgSpec(err) => {
                    assert_eq!(err, "found an invalid character for the package name");
                }
                nope => panic!("didn't expect {:?}", nope),
            }
        }
    }
}