use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
use crate::index::USER_AGENT;
use crate::validate::validate_name;
const API_BASE: &str = "https://crates.io/api/v1/crates";
const API_INTERVAL: Duration = Duration::from_secs(1);
static LAST_REQUEST: Mutex<Option<Instant>> = Mutex::new(None);
fn wait_for_slot(last: Option<Instant>, now: Instant) -> Duration {
last.map_or(Duration::ZERO, |prev| {
API_INTERVAL.saturating_sub(now.saturating_duration_since(prev))
})
}
fn throttle() {
let mut last = LAST_REQUEST.lock().unwrap_or_else(PoisonError::into_inner);
let wait = wait_for_slot(*last, Instant::now());
if !wait.is_zero() {
std::thread::sleep(wait);
}
*last = Some(Instant::now());
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub name: String,
pub version: String,
pub description: String,
}
#[derive(Deserialize)]
struct SearchBody {
crates: Vec<CrateEntry>,
}
#[derive(Deserialize)]
struct CrateEntry {
name: String,
#[serde(default)]
max_stable_version: Option<String>,
#[serde(default)]
default_version: Option<String>,
#[serde(default)]
max_version: Option<String>,
#[serde(default)]
description: Option<String>,
}
impl CrateEntry {
fn shown_version(&self) -> String {
[
&self.max_stable_version,
&self.default_version,
&self.max_version,
]
.into_iter()
.flatten()
.map(|v| sanitize_text(v))
.find(|v| !v.is_empty())
.unwrap_or_else(|| "?".to_owned())
}
}
pub fn search(query: &str, limit: usize) -> Result<Vec<Hit>> {
if query.trim().is_empty() {
bail!("empty search query");
}
throttle();
let response = ureq::get(API_BASE)
.set("User-Agent", USER_AGENT)
.query("q", query)
.query("per_page", &limit.to_string())
.call()
.map_err(|e| anyhow::anyhow!("crates.io search for `{query}` failed: {e}"))?;
let body = response
.into_string()
.context("reading crates.io search response")?;
let mut hits = parse_search_body(&body)?;
hits.truncate(limit);
Ok(hits)
}
fn sanitize_text(text: &str) -> String {
text.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn parse_search_body(body: &str) -> Result<Vec<Hit>> {
let parsed: SearchBody =
serde_json::from_str(body).context("malformed crates.io search response")?;
parsed
.crates
.into_iter()
.map(|c| {
let name = sanitize_text(&c.name);
validate_name(&name).with_context(|| {
format!("invalid crate name in crates.io search response: `{name}`")
})?;
Ok(Hit {
name,
version: c.shown_version(),
description: sanitize_text(c.description.as_deref().unwrap_or_default()),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_hits_with_optional_fields() {
let body = r#"{
"crates": [
{"name": "scx_beerland", "max_version": "1.1.3",
"max_stable_version": "1.1.3",
"description": "A sched_ext scheduler\n with odd spacing"},
{"name": "beerlang", "max_version": "0.2.0-alpha.1",
"max_stable_version": null, "description": null},
{"name": "bare", "max_version": "0.1.0"}
],
"meta": {"total": 3}
}"#;
let hits = parse_search_body(body).unwrap();
assert_eq!(
hits,
[
Hit {
name: "scx_beerland".to_owned(),
version: "1.1.3".to_owned(),
description: "A sched_ext scheduler with odd spacing".to_owned(),
},
Hit {
name: "beerlang".to_owned(),
version: "0.2.0-alpha.1".to_owned(),
description: String::new(),
},
Hit {
name: "bare".to_owned(),
version: "0.1.0".to_owned(),
description: String::new(),
},
]
);
}
#[test]
fn control_characters_never_reach_the_terminal() {
let body = r#"{"crates": [
{"name": "x", "max_version": "1.0.0",
"description": "foo\u001b[31mbar\u0007\nbaz\ttail"}
]}"#;
let hits = parse_search_body(body).unwrap();
assert_eq!(hits[0].description, "foo [31mbar baz tail");
assert!(!hits[0].description.chars().any(char::is_control));
let body = r#"{"crates": [
{"name": "evil\u001b[2Jname", "max_version": "1.0.0\u0007"}
]}"#;
assert!(parse_search_body(body).is_err());
let body = r#"{"crates": [{"name": "fine", "max_version": "1.0.0\u0007"}]}"#;
let hits = parse_search_body(body).unwrap();
assert_eq!(hits[0].version, "1.0.0");
}
#[test]
fn name_must_be_a_single_crate_name() {
for name in ["foo --locked", "foo bar", "../foo", ""] {
let body = format!(r#"{{"crates": [{{"name": "{name}", "max_version": "1.0.0"}}]}}"#);
assert!(parse_search_body(&body).is_err(), "{name:?}");
}
}
#[test]
fn throttle_arithmetic_honours_the_interval() {
let t0 = Instant::now();
assert_eq!(wait_for_slot(None, t0), Duration::ZERO);
let t1 = t0 + Duration::from_millis(300);
assert_eq!(wait_for_slot(Some(t0), t1), Duration::from_millis(700));
assert_eq!(wait_for_slot(Some(t0), t0 + API_INTERVAL), Duration::ZERO);
assert_eq!(
wait_for_slot(Some(t0), t0 + Duration::from_secs(5)),
Duration::ZERO
);
assert_eq!(wait_for_slot(Some(t1), t0), API_INTERVAL);
}
#[test]
fn version_falls_back_across_api_generations() {
let body = r#"{"crates": [
{"name": "stable", "max_stable_version": "1.0.0",
"default_version": "2.0.0-rc.1", "max_version": "2.0.0-rc.1"},
{"name": "newapi", "default_version": "3.1.0"},
{"name": "legacy", "max_version": "0.4.0"},
{"name": "nulls", "max_stable_version": null,
"default_version": null, "max_version": null},
{"name": "bare"}
]}"#;
let versions: Vec<String> = parse_search_body(body)
.unwrap()
.into_iter()
.map(|h| h.version)
.collect();
assert_eq!(versions, ["1.0.0", "3.1.0", "0.4.0", "?", "?"]);
}
#[test]
fn empty_result_set_is_not_an_error() {
let hits = parse_search_body(r#"{"crates": [], "meta": {"total": 0}}"#).unwrap();
assert_eq!(hits, Vec::<Hit>::new());
}
#[test]
fn malformed_body_is_an_error() {
assert!(parse_search_body("{").is_err());
assert!(parse_search_body(r#"{"crates": [{"max_version": "1.0.0"}]}"#).is_err());
assert!(parse_search_body(r#"{"notcrates": []}"#).is_err());
}
}