#![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, artifact: impl AsRef, exclude: &[impl AsRef], ) -> 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( artifact: impl AsRef, exclude: E, user: impl AsRef, repo: impl AsRef, ) -> Result where E: IntoIterator, S: AsRef, { 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( artifact: impl AsRef, exclude: E, host: impl AsRef, user: impl AsRef, repo: impl AsRef, ) -> Result where E: IntoIterator, S: AsRef, { 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 = 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()?) } }