use std::time::Duration;
use fetch_kit::Client;
use serde::{Deserialize, Serialize};
use crate::clock::unix_seconds;
use crate::error::Result;
use crate::model::{Observation, SourceKind, SubjectRecord};
use crate::source::{CollectCtx, CollectReport, TrendSource};
const CRATES_API: &str = "https://crates.io/api/v1";
const USER_AGENT: &str = "trendkit/0.2 (https://github.com/WyattAu/trendkit)";
const PER_PAGE: u32 = 100;
pub const CRATES_PACING: Duration = Duration::from_millis(1_200);
#[derive(Debug, Clone)]
pub struct CratesSource {
client: Client,
}
impl CratesSource {
#[must_use]
pub fn new() -> CratesSource {
CratesSource {
client: Client::builder().user_agent(USER_AGENT).build(),
}
}
}
impl Default for CratesSource {
fn default() -> Self {
CratesSource::new()
}
}
impl TrendSource for CratesSource {
fn name(&self) -> &'static str {
"cratesio"
}
fn collect(
&self,
ctx: &CollectCtx,
) -> impl std::future::Future<Output = Result<CollectReport>> {
let ctx = ctx.clone();
async move {
let now = unix_seconds(ctx.clock.as_ref());
let mut total = 0usize;
for page in 1..=ctx.pages {
let params = CratesQuery {
sort: "downloads",
per_page: PER_PAGE,
page,
};
let url = format!("{CRATES_API}/crates");
let response: CratesResponse =
self.client.get(url).query(¶ms).json_response().await?;
let count = response.crates.len();
for krate in response.crates {
let observation = Observation {
ts: now,
score: krate.downloads,
extra1: krate.recent_downloads.map(|f| f as i64).unwrap_or(0),
extra2: 0,
};
let subject = SubjectRecord::from(krate);
let subject_id = ctx.store.upsert_subject(&subject)?;
ctx.store.insert_observation(subject_id, &observation)?;
total += 1;
}
if count < PER_PAGE as usize || page == ctx.pages {
break;
}
tokio::time::sleep(CRATES_PACING).await;
}
ctx.store
.record_run(now, self.name(), i64::from(ctx.pages), total as i64)?;
Ok(CollectReport {
source: self.name(),
items: total,
pages: ctx.pages as usize,
})
}
}
}
#[derive(Debug, Serialize)]
struct CratesQuery<'a> {
sort: &'a str,
per_page: u32,
page: u32,
}
#[derive(Debug, Deserialize)]
struct CratesResponse {
#[serde(default)]
crates: Vec<CrateJson>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] pub(crate) struct CrateJson {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) description: Option<String>,
#[serde(default)]
pub(crate) downloads: i64,
#[serde(default)]
pub(crate) recent_downloads: Option<f64>,
#[serde(default)]
pub(crate) max_version: Option<String>,
#[serde(default)]
pub(crate) created_at: Option<String>,
#[serde(default)]
pub(crate) keywords: Option<Vec<String>>,
}
impl From<CrateJson> for SubjectRecord {
fn from(json: CrateJson) -> Self {
let url = format!("https://crates.io/crates/{}", json.name);
SubjectRecord {
kind: SourceKind::Crate,
key: json.name,
title: json.description,
lang: None,
url: Some(url),
topics: json.keywords.unwrap_or_default(),
created_at: json.created_at,
}
}
}
pub fn parse_crates_json(payload: &str) -> Result<Vec<SubjectRecord>> {
let response: CratesResponse = serde_json::from_str(payload)
.map_err(|e| crate::error::TrendError::Config(format!("invalid crates payload: {e}")))?;
Ok(response
.crates
.into_iter()
.map(SubjectRecord::from)
.collect())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
const SAMPLE: &str = r#"{
"crates": [
{
"name": "serde",
"description": "A serialization framework",
"downloads": 300000000,
"recent_downloads": 12345678.0,
"max_version": "1.0.220",
"created_at": "2016-01-05T00:00:00Z",
"keywords": ["serialization", "no_std"]
},
{
"name": "bare-crate",
"downloads": 99
}
],
"meta": { "total": 2 }
}"#;
#[test]
fn parse_extracts_crates_with_defaults() {
let subjects = parse_crates_json(SAMPLE).unwrap();
assert_eq!(subjects.len(), 2);
assert_eq!(subjects[0].kind, SourceKind::Crate);
assert_eq!(subjects[0].key, "serde");
assert_eq!(
subjects[0].title.as_deref(),
Some("A serialization framework")
);
assert_eq!(
subjects[0].topics,
vec!["serialization".to_owned(), "no_std".to_owned()]
);
assert_eq!(subjects[1].topics, Vec::<String>::new());
assert_eq!(subjects[1].title, None);
}
#[test]
fn parse_rejects_garbage() {
assert!(parse_crates_json("<html>").is_err());
}
}