#![warn(missing_docs)]
use anyhow::{anyhow, bail, Context, Error};
use flate2::read::GzDecoder;
use regex::Regex;
use reqwest::StatusCode;
use semver;
use serde_json::Value;
use std::env;
use std::path::PathBuf;
use std::process::Command;
use tar::Archive;
#[macro_use]
extern crate log;
static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
pub const DEFAULT_BITBUCKET_URL: &'static str = "https://api.bitbucket.org/2.0/repositories";
pub const DEFAULT_GITHUB_URL: &'static str = "https://github.com";
pub const DEFAULT_GITLAB_URL: &'static str = "https://gitlab.com";
pub const DEFAULT_REGISTRY_URL: &'static str = "https://crates.io";
#[derive(Debug, Clone)]
pub enum CloneMethodKind {
Crate,
Git,
Mercurial,
Pijul,
Fossil,
Auto,
}
impl CloneMethodKind {
pub fn command(&self) -> &str {
match *self {
CloneMethodKind::Crate => "crate",
CloneMethodKind::Git => "git",
CloneMethodKind::Mercurial => "hg",
CloneMethodKind::Pijul => "pijul",
CloneMethodKind::Fossil => "fossil",
CloneMethodKind::Auto => "auto",
}
}
pub fn from(method_name: &str) -> Option<CloneMethodKind> {
match method_name {
"crate" => Some(CloneMethodKind::Crate),
"git" => Some(CloneMethodKind::Git),
"hg" => Some(CloneMethodKind::Mercurial),
"mercurial" => Some(CloneMethodKind::Mercurial),
"pijul" => Some(CloneMethodKind::Pijul),
"fossil" => Some(CloneMethodKind::Fossil),
"auto" => Some(CloneMethodKind::Auto),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Cloner {
registry_url: String,
github_url: String,
gitlab_url: String,
bitbutcket_url: String,
out_dir: Option<PathBuf>,
}
fn check_semver_req(version: &str) -> Result<String, Error> {
let first = version
.chars()
.nth(0)
.ok_or_else(|| anyhow!("version is empty"))?;
let is_req = "<>=^~".contains(first) || version.contains('*');
if is_req {
Ok(version.parse::<semver::VersionReq>()?.to_string())
} else {
match semver::Version::parse(version) {
Ok(v) => Ok(format!("={}", v)),
Err(e) => Err(e).context(anyhow!(
"`{}` is not a valid semver version.\n\
Use an exact version like 1.2.3 or a version requirement expression.",
version
))?,
}
}
}
fn get_repo(pkg_info: &Value) -> Result<Option<String>, Error> {
let krate = pkg_info
.get("crate")
.ok_or_else(|| anyhow!("`crate` expected in pkg info"))?;
let repo = &krate["repository"];
if repo.is_string() {
return Ok(Some(repo.as_str().unwrap().to_string()));
}
let home = &krate["homepage"];
if home.is_string() {
return Ok(Some(home.as_str().unwrap().to_string()));
}
Ok(None)
}
fn reqwest_get(url: &str) -> reqwest::Result<reqwest::blocking::Response> {
let client = reqwest::blocking::Client::builder()
.user_agent(APP_USER_AGENT)
.build()?;
client.get(url).send()
}
impl Cloner {
pub fn new() -> Cloner {
Cloner {
registry_url: DEFAULT_REGISTRY_URL.to_string(),
github_url: DEFAULT_GITHUB_URL.to_string(),
gitlab_url: DEFAULT_GITLAB_URL.to_string(),
bitbutcket_url: DEFAULT_BITBUCKET_URL.to_string(),
out_dir: None,
}
}
pub fn set_registry_url(&mut self, value: impl Into<String>) -> &mut Self {
self.registry_url = value.into();
self
}
pub fn set_github_url(&mut self, value: impl Into<String>) -> &mut Self {
self.github_url = value.into();
self
}
pub fn set_gitlab_url(&mut self, value: impl Into<String>) -> &mut Self {
self.gitlab_url = value.into();
self
}
pub fn set_bitbucket_url(&mut self, value: impl Into<String>) -> &mut Self {
self.bitbutcket_url = value.into();
self
}
pub fn set_out_dir(&mut self, value: impl Into<PathBuf>) -> &mut Self {
self.out_dir = Some(value.into());
self
}
fn out_dir(&self) -> Result<PathBuf, Error> {
Ok(self
.out_dir
.as_ref()
.map_or_else(|| env::current_dir(), |v| Ok(v.to_path_buf()))?)
}
pub fn clone(
&self,
method_kind: CloneMethodKind,
spec: &str,
version: Option<&str>,
extra: &[&str],
) -> Result<(), Error> {
let mut parts = spec.splitn(2, ':');
let name = parts.next().unwrap();
let spec_version_req = parts.next();
if spec_version_req.is_some() && version.is_some() {
bail!("Cannot specify both a :version and --version.");
}
let version_req = version
.or(spec_version_req)
.map(check_semver_req)
.transpose()?;
let pkg_info = self.get_pkg_info(name)?;
let repo = get_repo(&pkg_info)?;
let (method, repo) = match method_kind {
CloneMethodKind::Auto => {
if version_req.is_some() {
(CloneMethodKind::Crate, "".to_string())
} else if let Some(repo) = repo {
self.detect_repo(&repo)?
} else {
(CloneMethodKind::Crate, "".to_string())
}
}
CloneMethodKind::Crate => (method_kind, "".to_string()),
_ => {
if repo.is_none() {
bail!("Could not find repository path in crates.io.");
}
(method_kind, repo.unwrap())
}
};
match method {
CloneMethodKind::Crate => {
if !extra.is_empty() {
bail!("Got extra arguments, crate downloads take no extra arguments.");
}
self.clone_crate(name, version_req, &pkg_info)?;
}
CloneMethodKind::Git
| CloneMethodKind::Mercurial
| CloneMethodKind::Pijul
| CloneMethodKind::Fossil => {
if let Some(version_req) = version_req {
bail!(
"Specifying a version `{}` only works with the `crate` method.",
version_req
);
}
self.run_clone(method.command(), &repo, extra)?;
}
CloneMethodKind::Auto => unreachable!(),
}
Ok(())
}
fn detect_repo(&self, repo: &str) -> Result<(CloneMethodKind, String), Error> {
if repo.ends_with(".git") {
return Ok((CloneMethodKind::Git, repo.to_string()));
}
if let Some(c) = Regex::new(r"https?://(?:www\.)?github\.com/([^/]+)/([^/]+)")
.unwrap()
.captures(repo)
{
return Ok((
CloneMethodKind::Git,
format!(
"{}/{}/{}.git",
self.github_url,
c.get(1).unwrap().as_str(),
c.get(2).unwrap().as_str()
),
));
}
if let Some(c) = Regex::new(r"https?://(?:www\.)?gitlab\.com/([^/]+)/([^/]+)")
.unwrap()
.captures(repo)
{
return Ok((
CloneMethodKind::Git,
format!(
"{}/{}/{}.git",
self.gitlab_url,
c.get(1).unwrap().as_str(),
c.get(2).unwrap().as_str()
),
));
}
if let Some(c) = Regex::new(r"https?://(?:www\.)?bitbucket\.(?:org|com)/([^/]+)/([^/]+)")
.unwrap()
.captures(repo)
{
let user = c.get(1).unwrap().as_str();
let name = c.get(2).unwrap().as_str();
return self.bitbucket(user, name);
}
if repo.starts_with("https://nest.pijul.com/") {
return Ok((CloneMethodKind::Pijul, repo.to_string()));
}
bail!(
"Could not determine the VCS from repo `{}`, \
use the `--method` option to specify how to download.",
repo
);
}
fn bitbucket(&self, user: &str, name: &str) -> Result<(CloneMethodKind, String), Error> {
let api_url = &format!("{}/{}/{}", self.bitbutcket_url, user, name);
let repo_info =
reqwest_get(api_url).context("Failed to fetch repo info from bitbucket.")?;
let code = repo_info.status();
if !code.is_success() {
bail!(
"Failed to get repo info from bitbucket API `{}`: `{}`",
api_url,
code
);
}
let repo_info: Value = repo_info
.json()
.context("Failed to convert to bitbucket json.")?;
let method = repo_info["scm"]
.as_str()
.expect("Could not get `scm` from bitbucket.");
let method = match method {
"git" => CloneMethodKind::Git,
"hg" => CloneMethodKind::Mercurial,
_ => bail!("Unexpected bitbucket scm: `{}`", method),
};
let clones = repo_info["links"]["clone"]
.as_array()
.expect("Could not get `clone` from bitbucket.");
let href = clones
.iter()
.find(|c| {
c["name"]
.as_str()
.expect("Could not get clone `name` from bitbucket.")
== "https"
})
.expect("Could not find `https` clone in bitbucket.")["href"]
.as_str()
.expect("Could not get clone `href` from bitbucket.");
Ok((method, href.to_string()))
}
fn get_pkg_info(&self, name: &str) -> Result<Value, Error> {
let pkg_info = reqwest_get(&format!("{}/api/v1/crates/{}", self.registry_url, name))
.context("Failed to fetch package info from crates.io.")?;
let code = pkg_info.status();
match code {
StatusCode::OK => {}
StatusCode::NOT_FOUND => bail!("Package `{}` not found on crates.io.", name),
_ => bail!("Failed to get package info from crates.io: `{}`", code),
}
let pkg_info: Value = pkg_info.json().context("Failed to convert to json.")?;
Ok(pkg_info)
}
fn clone_crate(
&self,
name: &str,
version_req: Option<String>,
pkg_info: &Value,
) -> Result<(), Error> {
let versions = pkg_info["versions"]
.as_array()
.expect("Could not find `versions` array on crates.io.");
let versions = versions.iter().map(|crate_version| {
let num = crate_version["num"]
.as_str()
.expect("Could not get `num` from version.");
let v = semver::Version::parse(num).expect("Could not parse crate `num`.");
(crate_version, v)
});
let mut versions: Vec<_> = if let Some(version_req) = version_req {
let req = semver::VersionReq::parse(&version_req)?;
versions
.filter(|(_crate_version, ver)| req.matches(ver))
.collect()
} else {
versions.collect()
};
if versions.is_empty() {
bail!("Could not find any matching versions.");
}
versions.sort_unstable_by_key(|x| x.1.clone());
let last = versions.last().unwrap().0;
let dl_path = last["dl_path"]
.as_str()
.expect("Could not find `dl_path` in crate version info.");
let dl_path = format!("{}{}", self.registry_url, dl_path);
let version = last["num"]
.as_str()
.expect("Could not find `num` in crate version info.");
info!("Downloading `{}`", dl_path);
let mut response =
reqwest_get(&dl_path).context(format!("Failed to download `{}`", dl_path))?;
let mut body = Vec::new();
response.copy_to(&mut body)?;
let gz = GzDecoder::new(body.as_slice());
let mut tar = Archive::new(gz);
let base = format!("{}-{}", name.to_lowercase(), version);
for entry in tar.entries()? {
let mut entry = entry.context("Failed to get tar entry.")?;
let entry_path = entry
.path()
.context("Failed to read entry path.")?
.into_owned();
info!("{}", entry_path.display());
if !entry_path.starts_with(&base) {
bail!(
"Expected path `{}` in tarball, got `{}`.",
base,
entry_path.display()
);
}
entry.unpack_in(&self.out_dir()?).context(format!(
"failed to unpack entry at `{}`",
entry_path.display()
))?;
}
Ok(())
}
fn run_clone(&self, method: &str, repo: &str, extra: &[&str]) -> Result<(), Error> {
info!("Running: {} clone {} {}", method, repo, extra.join(" "));
let status = Command::new(method)
.arg("clone")
.arg(repo)
.args(extra)
.current_dir(&self.out_dir()?)
.status()
.context(format!("Failed to run `{}`.", method))?;
if !status.success() {
bail!("`{} clone` did not finish successfully.", method);
}
Ok(())
}
}
pub fn clone(
method_name: &str,
spec: &str,
version: Option<&str>,
extra: &[&str],
) -> Result<(), Error> {
Cloner::new().clone(
CloneMethodKind::from(method_name).unwrap(),
spec,
version,
extra,
)
}