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()?)
    }
}