krates 0.1.1

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)]
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(ref vers) = self.version {
            if vers != &krate.version {
                return false;
            }
        }

        match self.url {
            Some(ref u) => {
                // Get the url from the identifier to avoid pointless
                // allocations.
                if let Some(mut url) = krate.id.repr.splitn(3, ' ').nth(2) {
                    // Strip off the the enclosing parens
                    url = &url[1..url.len() - 1];

                    // Strip off the leading <source>+
                    if let Some(ind) = url.find('+') {
                        url = &url[ind + 1..];
                    }

                    // Strip off any fragments
                    if let Some(ind) = url.rfind('#') {
                        url = &url[..ind];
                    }

                    // Strip off any query parts
                    if let Some(ind) = url.rfind('?') {
                        url = &url[..ind];
                    }

                    u == url
                } else {
                    false
                }
            }
            None => true,
        }
    }
}

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(|_| {
                                    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(|_| {
                                            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))
            };

            match url.find('#') {
                Some(ind) => {
                    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,
                    })
                }
                None => {
                    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() {
        let spec: PkgSpec = "bitflags:1.0.4".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() {
        let spec: PkgSpec = "https://github.com/rust-lang/cargo#crates-io:0.21.0"
            .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),
            }
        }
    }
}