ingredients 0.1.1

Check ingredients of published Rust crates
Documentation
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use reqwest::StatusCode;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::Deserialize;
use tempfile::{NamedTempFile, TempDir};

use crate::error::Error;
use crate::metadata::{Metadata, get_crate_metadata};
use crate::utils::{self, DirectoryContents, get_contents};

// these files are expected to be different in published crates
const FILTERED_FILE_NAMES: [&str; 4] = ["Cargo.toml", "Cargo.toml.orig", "Cargo.lock", ".cargo_vcs_info.json"];

const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), " v", env!("CARGO_PKG_VERSION"));

pub(crate) static CRATES_IO_CLIENT: LazyLock<CratesIoClient> = LazyLock::new(CratesIoClient::init);

#[derive(Deserialize)]
struct CratesIoVersions {
    versions: Vec<CratesIoVersion>,
}

#[derive(Deserialize)]
struct CratesIoVersion {
    #[serde(rename = "num")]
    version: String,
    // incomplete
}

pub struct CratesIoClient {
    client: reqwest::Client,
    // incomplete
}

impl CratesIoClient {
    pub fn init() -> Self {
        let mut headers = HeaderMap::new();
        headers.insert(
            HeaderName::from_static("user-agent"),
            HeaderValue::from_static(USER_AGENT),
        );

        // this is not an error that can reasonably be handled
        #[allow(clippy::expect_used)]
        let client = reqwest::ClientBuilder::new()
            .default_headers(headers)
            .build()
            .expect("Cannot initialize HTTP client.");

        CratesIoClient { client }
    }

    pub(crate) async fn versions(&self, name: &str) -> Result<Vec<String>, Error> {
        let url = format!("https://crates.io/api/v1/crates/{name}/versions");

        let response = self.client.get(url).send().await?;

        if response.status() == StatusCode::NOT_FOUND {
            // 404 NOT FOUND is returned if the name is unknown
            return Err(Error::CrateNotFound {
                name: String::from(name),
            });
        }

        let contents = response.text().await?;
        // if this fails, something is seriously wrong - just panic
        #[allow(clippy::expect_used)]
        let json: CratesIoVersions =
            serde_json::from_str(&contents).expect("Failed to deserialize JSON from crates.io API response.");

        Ok(json.versions.into_iter().map(|v| v.version).collect())
    }

    pub(crate) async fn download(&self, name: &str, version: &str) -> Result<NamedTempFile, Error> {
        let url = format!("https://crates.io/api/v1/crates/{name}/{version}/download");

        let versions = self.versions(name).await?;

        if !versions.iter().any(|v| v == version) {
            return Err(Error::VersionNotFound {
                name: String::from(name),
                version: String::from(version),
            });
        }

        let mut temp = NamedTempFile::new()?;

        let mut response = self.client.get(url).send().await?;
        response.error_for_status_ref()?;

        // streaming download to avoid having the entire file contents in memory
        while let Some(bytes) = response.chunk().await? {
            temp.write_all(&bytes)?;
        }

        Ok(temp)
    }
}

/// Crate archive
#[derive(Debug)]
#[non_exhaustive]
pub struct Crate {
    pub(crate) metadata: Metadata,
    pub(crate) root: PathBuf,
    _temp: Option<TempDir>,
}

impl Crate {
    /// Load crate sources from local ".crate" file.
    ///
    /// ```rust,no_run
    /// use ingredients::Crate;
    ///
    /// let _krate = Crate::local("ingredients", "0.1.0", "./ingredients-0.1.0.crate").unwrap();
    /// ```
    ///
    /// # Errors
    ///
    /// * The specified path does not exist.
    /// * The file at the specified path is not a crate archive (.tar.gz format).
    /// * The crate cannot be unpacked in a temporary location.
    /// * The crate does not contain valid crate metadata.
    pub fn local<P: AsRef<Path>>(name: &str, version: &str, path: P) -> Result<Self, Error> {
        let temp = utils::unpack_tar_gz(path)?;

        let root = temp.path().join(format!("{name}-{version}"));
        let metadata = get_crate_metadata(root.clone())?;

        Ok(Crate {
            metadata,
            root,
            _temp: Some(temp),
        })
    }

    /// Download crate sources from crates.io.
    ///
    /// In `async` contexts:
    ///
    /// ```rust,no_run
    /// use ingredients::Crate;
    /// # use tokio::runtime::Runtime;
    ///
    /// # let rt = Runtime::new().unwrap();
    /// # rt.block_on(async {
    /// let _krate = Crate::download("ingredients", "0.1.0").await.unwrap();
    /// # })
    /// ```
    ///
    /// Outside of `async` contexts:
    ///
    /// ```rust,no_run
    /// use ingredients::Crate;
    /// use tokio::runtime::Runtime;
    ///
    /// let rt = Runtime::new().unwrap();
    /// let _krate = rt
    ///     .block_on(Crate::download("ingredients", "0.1.0"))
    ///     .unwrap();
    /// ```
    ///
    /// # Errors
    ///
    /// * The network connection to crates.io fails.
    /// * The specified crate does not exist.
    /// * The specified version does not exist.
    /// * The downloaded file is not a crate archive (.tar.gz format).
    /// * The crate cannot be unpacked in a temporary location.
    /// * The crate does not contain valid crate metadata.
    pub async fn download(name: &str, version: &str) -> Result<Self, Error> {
        log::debug!("Downloading: '{name}' v{version} ...",);

        let dl = CRATES_IO_CLIENT.download(name, version).await?;
        let temp = utils::unpack_tar_gz(dl)?;

        let root = temp.path().join(format!("{name}-{version}"));
        let metadata = get_crate_metadata(root.clone())?;

        Ok(Crate {
            metadata,
            root,
            _temp: Some(temp),
        })
    }

    pub(crate) fn cargo_toml_orig(&self) -> PathBuf {
        self.root.join("Cargo.toml.orig")
    }

    pub(crate) fn file_contents(&self) -> Result<DirectoryContents, Error> {
        let mut contents = get_contents(&self.root, &self.root)?;

        contents.files.retain(|f| {
            f.file_name()
                .is_some_and(|s| !FILTERED_FILE_NAMES.iter().any(|f| *f == s))
        });

        Ok(contents)
    }

    pub(crate) fn read_entry_to_bytes<P: AsRef<Path>>(&self, path: P) -> io::Result<Vec<u8>> {
        utils::read_to_bytes(self.root.join(path))
    }

    #[allow(unused)]
    pub(crate) fn read_entry_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
        std::fs::read_to_string(self.root.join(path))
    }
}