lib.rs
raw
#![forbid(unsafe_code)]
//! While gird is primarily developed as a command-line tool, it can
//! also be used as a library by other programs. Using this library,
//! it is simple to retrieve release artifacts from supported sources.
use ureq::Response;
#[non_exhaustive]
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Regex error: {0}")]
Regex(#[from] regex::Error),
#[error("I/O error: {0}")]
IO(#[from] std::io::Error),
#[error("ureq error: {0}")]
UReq(#[from] ureq::Error),
#[error("JSON error")]
SerdeJson(#[from] serde_json::Error),
#[error("No such release found")]
ReleaseNotFound,
#[error("No matching artifacts found")]
NoMatches,
#[error("Artifact download URL not found")]
UrlNotFound,
#[error("{0}")]
Misc(String),
}
/// Checks whether `fname` is a superstring of `artifact` and not a
/// superstring of anything in `excludes`.
///
/// `&[""; 0]` can be used as a value for the exclude parameter if you
/// do not wish to exclude any file name patterns. Simple `&[]` won't
/// work because the compiler will not be able to infer the contained
/// type of the slice.
pub fn is_match(
fname: impl AsRef<str>,
artifact: impl AsRef<str>,
exclude: &[impl AsRef<str>],
) -> bool {
let fname = fname.as_ref();
let artifact = artifact.as_ref();
if fname.contains(artifact) {
if exclude.iter().any(|e| fname.contains(e.as_ref())) {
false
} else {
true
}
} else {
false
}
}
pub mod github {
//! GitHub-specific functions and utilities.
/// GitHub-specific downloader for the file in the latest release
/// from repository `user`/`repo` which matches the `artifact`
/// string.
///
/// The exclude iterator, if non-empty, provides a list of
/// substrings which will invalidate a particular artifact from
/// being an acceptable match for the download. For example, if
/// exclude is set to `vec!["musl"].iter()` then a release
/// artifact such as **foobar-v12.6.1-linux-amd64-musl.tar.gz**
/// would not be downloaded, even if the artifact was set to
/// `"linux-amd64"`
///
/// `std::iter::empty::<&str>()` can be used as a value for the
/// exclude parameter if you do not wish to exclude any file name
/// patterns.
pub fn download_release<E, S>(
artifact: impl AsRef<str>,
exclude: E,
user: impl AsRef<str>,
repo: impl AsRef<str>,
) -> Result<super::Response, super::Error>
where
E: IntoIterator<Item = S>,
S: AsRef<str>,
{
let user = user.as_ref();
let repo = repo.as_ref();
let artifact = artifact.as_ref();
let exclude: Vec<_> = exclude
.into_iter()
.map(|x| x.as_ref().to_string())
.collect();
let agent = ureq::AgentBuilder::new().redirects(0).build();
let artifact_pattern =
regex::RegexBuilder::new(r#" href="(.*?/releases/download/.*?)""#).build()?;
let Some(tag) = agent
.get(format!("https://github.com/{}/{}/releases/latest", user, repo).as_str())
.call()?
.header("Location")
.and_then(|loc| loc.rsplit_once("/tag/").map(|parts| parts.1.to_string()))
else {
return Err(super::Error::ReleaseNotFound);
};
let artifacts_page = agent
.get(
format!(
"https://github.com/{}/{}/releases/expanded_assets/{}",
user, repo, &tag
)
.as_str(),
)
.call()?
.into_string()?;
let artifact_urls: Vec<_> = artifact_pattern
.captures_iter(artifacts_page.as_str())
.flat_map(|c| {
c.get(1).and_then(|m| {
let m = m.as_str();
if super::is_match(m, artifact, exclude.as_slice()) {
Some(String::from(m))
} else {
None
}
})
})
.collect();
if let Some(path) = artifact_urls.into_iter().next() {
let Some(redir) = agent
.get(format!("https://github.com{}", &path).as_str())
.call()?
.header("Location")
.map(|loc| loc.to_string())
else {
return Err(super::Error::UrlNotFound);
};
Ok(agent.get(&redir).call()?)
} else {
Err(super::Error::NoMatches)
}
}
}
pub mod gitlab {
//! GitLab-specific functions and utilities.
/// GitLab-specific downloader for the file in the latest release
/// from repository `user`/`repo` on `host` which matches the
/// `artifact` string.
///
/// The exclude iterator, if non-empty, provides a list of
/// substrings which will invalidate a particular artifact from
/// being an acceptable match for the download. For example, if
/// exclude is set to `vec!["musl"].iter()` then a release
/// artifact such as **foobar-v12.6.1-linux-amd64-musl.tar.gz**
/// would not be downloaded, even if the artifact was set to
/// `"linux-amd64"`
///
/// `std::iter::empty::<&str>()` can be used as a value for the
/// exclude parameter if you do not wish to exclude any file name
/// patterns.
pub fn download_release<E, S>(
artifact: impl AsRef<str>,
exclude: E,
host: impl AsRef<str>,
user: impl AsRef<str>,
repo: impl AsRef<str>,
) -> Result<super::Response, super::Error>
where
E: IntoIterator<Item = S>,
S: AsRef<str>,
{
let host = host.as_ref();
let user = user.as_ref();
let repo = repo.as_ref();
let artifact = artifact.as_ref();
let exclude: Vec<_> = exclude
.into_iter()
.map(|x| x.as_ref().to_string())
.collect();
let agent = ureq::AgentBuilder::new().redirects(0).build();
let project_id_pattern =
regex::RegexBuilder::new(r#"\sdata-project-id="(\d+)""#).build()?;
let Some(project_id) = project_id_pattern
.captures(
&agent
.get(format!("https://{}/{}/{}/-/releases", host, user, repo).as_str())
.call()?
.into_string()?,
)
.map(|c| c.extract::<1>().1[0].to_string())
else {
return Err(super::Error::ReleaseNotFound);
};
let json: serde_json::Value = serde_json::from_reader(
agent
.get(format!("https://{}/api/v4/projects/{}/releases", host, project_id).as_str())
.call()?
.into_reader(),
)?;
let Some(sources) = &json[0]["assets"]["sources"].as_array() else {
return Err(super::Error::ReleaseNotFound);
};
let matches: Vec<String> = sources
.iter()
.flat_map(|s| s["url"].as_str().map(|u| u.to_string()))
.filter(|u| super::is_match(u, artifact, exclude.as_slice()))
.collect();
if matches.is_empty() {
return Err(super::Error::NoMatches);
}
Ok(agent.get(&matches[0]).call()?)
}
}