use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SparseIndexEntry {
pub name: String,
pub vers: String,
#[serde(default)]
pub deps: Vec<SparseIndexDep>,
pub cksum: String,
#[serde(default)]
pub yanked: bool,
#[serde(default)]
pub features: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SparseIndexDep {
pub name: String,
pub req: String,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub optional: bool,
#[serde(default = "default_true")]
pub default_features: bool,
pub target: Option<String>,
pub kind: Option<String>,
pub package: Option<String>,
}
fn default_true() -> bool {
true
}
pub struct SparseIndexClient {
base_url: String,
cache_dir: PathBuf,
agent: ureq::Agent,
}
impl SparseIndexClient {
pub fn new() -> Result<Self> {
Self::for_cache_dir(None)
}
pub fn for_cache_dir(cache_dir: Option<&Path>) -> Result<Self> {
Self::with_cache_dir(
crate::core::cache_root::resolve(cache_dir).join(crate::core::cache_root::INDEX_DIR),
)
}
pub fn with_cache_dir(cache_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&cache_dir)?;
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.max_idle_connections(64)
.max_idle_connections_per_host(64)
.build();
let base_url = std::env::var("MITTS_SPARSE_INDEX_URL")
.unwrap_or_else(|_| "https://index.crates.io".to_string());
Ok(Self {
base_url,
cache_dir,
agent,
})
}
pub fn relative_path(crate_name: &str) -> String {
let lower = crate_name.to_lowercase();
match lower.len() {
0 => String::new(),
1 => format!("1/{}", lower),
2 => format!("2/{}", lower),
3 => format!("3/{}/{}", &lower[0..1], lower),
_ => format!("{}/{}/{}", &lower[0..2], &lower[2..4], lower),
}
}
pub fn fetch_crate_versions(
&self,
crate_name: &str,
offline: bool,
) -> Result<Vec<SparseIndexEntry>> {
let rel_path = Self::relative_path(crate_name);
if rel_path.is_empty() {
bail!("Empty crate name provided");
}
let local_cache_path = self.cache_dir.join(&rel_path);
if local_cache_path.is_file() {
if let Ok(entries) = self.parse_index_file(&local_cache_path) {
if !entries.is_empty() {
return Ok(entries);
}
}
}
if offline {
bail!(
"Crate '{}' not in local index cache and --offline requested",
crate_name
);
}
let url = format!("{}/{}", self.base_url, rel_path);
let resp = match self.agent.get(&url).call() {
Ok(r) => r,
Err(ureq::Error::Status(404, _)) => {
bail!(
"Crate '{}' not found in crates.io index ({})",
crate_name,
url
);
}
Err(e) => {
bail!(
"Failed to fetch sparse index for '{}' from {}: {}",
crate_name,
url,
e
);
}
};
let content = resp.into_string().with_context(|| {
format!(
"Failed to read sparse index response for '{}' from {}",
crate_name, url
)
})?;
if let Some(parent) = local_cache_path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&local_cache_path, &content);
Self::parse_lines(&content)
}
fn parse_index_file(&self, path: &Path) -> Result<Vec<SparseIndexEntry>> {
let content = fs::read_to_string(path)?;
Self::parse_lines(&content)
}
pub fn parse_lines(content: &str) -> Result<Vec<SparseIndexEntry>> {
let mut entries = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<SparseIndexEntry>(trimmed) {
entries.push(entry);
}
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relative_path() {
assert_eq!(SparseIndexClient::relative_path("a"), "1/a");
assert_eq!(SparseIndexClient::relative_path("ab"), "2/ab");
assert_eq!(SparseIndexClient::relative_path("abc"), "3/a/abc");
assert_eq!(SparseIndexClient::relative_path("abcd"), "ab/cd/abcd");
assert_eq!(SparseIndexClient::relative_path("serde"), "se/rd/serde");
assert_eq!(
SparseIndexClient::relative_path("serde_json"),
"se/rd/serde_json"
);
}
#[test]
fn test_parse_lines() {
let json_lines = r#"{"name":"foo","vers":"1.0.0","deps":[],"cksum":"deadbeef","features":{},"yanked":false}
{"name":"foo","vers":"1.1.0","deps":[],"cksum":"cafebabe","features":{},"yanked":true}"#;
let entries = SparseIndexClient::parse_lines(json_lines).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].vers, "1.0.0");
assert!(!entries[0].yanked);
assert_eq!(entries[1].vers, "1.1.0");
assert!(entries[1].yanked);
}
}