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 tracing::{debug, trace};
use crate::error::Error;
use crate::metadata::{Metadata, get_crate_metadata};
use crate::utils::{self, DirectoryContents, get_contents};
use crate::vcsinfo::{CargoVcsInfo, vcs_info_from_root};
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,
}
pub struct CratesIoClient {
client: reqwest::Client,
}
impl CratesIoClient {
pub fn init() -> Self {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("user-agent"),
HeaderValue::from_static(USER_AGENT),
);
debug!("Initializing HTTP client");
#[expect(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");
debug!("Querying crates.io API for available versions: {name}");
let response = self.client.get(url).send().await?;
if response.status() == StatusCode::NOT_FOUND {
return Err(Error::CrateNotFound {
name: String::from(name),
});
}
let contents = response.text().await?;
#[expect(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()?;
debug!("Downloading crate from crates.io: {name}@{version}");
let mut response = self.client.get(url).send().await?;
response.error_for_status_ref()?;
trace!(
"Writing crate download to temporary file: {}",
temp.path().to_string_lossy()
);
while let Some(bytes) = response.chunk().await? {
temp.write_all(&bytes)?;
}
Ok(temp)
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Crate {
pub(crate) metadata: Metadata,
pub(crate) root: PathBuf,
pub(crate) vcs_info: Option<CargoVcsInfo>,
_temp: Option<TempDir>,
}
impl Crate {
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())?;
let vcs_info = vcs_info_from_root(&root)?;
Ok(Crate {
metadata,
root,
vcs_info,
_temp: Some(temp),
})
}
pub async fn download(name: &str, version: &str) -> Result<Self, Error> {
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())?;
let vcs_info = vcs_info_from_root(&root)?;
Ok(Crate {
metadata,
root,
vcs_info,
_temp: Some(temp),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.metadata.inner.name
}
#[must_use]
pub fn version(&self) -> String {
self.metadata.inner.version.to_string()
}
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>> {
trace!("Reading bytes from file: {}", path.as_ref().to_string_lossy());
utils::read_to_bytes(self.root.join(path))
}
pub(crate) fn read_entry_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
trace!("Reading text from file: {}", path.as_ref().to_string_lossy());
std::fs::read_to_string(self.root.join(path))
}
}