trendkit_core/
cratesio.rs1use std::time::Duration;
8
9use fetch_kit::Client;
10use serde::{Deserialize, Serialize};
11
12use crate::clock::unix_seconds;
13use crate::error::Result;
14use crate::model::{Observation, SourceKind, SubjectRecord};
15use crate::source::{CollectCtx, CollectReport, TrendSource};
16
17const CRATES_API: &str = "https://crates.io/api/v1";
18const USER_AGENT: &str = "trendkit/0.2 (https://github.com/WyattAu/trendkit)";
19const PER_PAGE: u32 = 100;
20pub const CRATES_PACING: Duration = Duration::from_millis(1_200);
22
23#[derive(Debug, Clone)]
25pub struct CratesSource {
26 client: Client,
27}
28
29impl CratesSource {
30 #[must_use]
32 pub fn new() -> CratesSource {
33 CratesSource {
34 client: Client::builder().user_agent(USER_AGENT).build(),
35 }
36 }
37}
38
39impl Default for CratesSource {
40 fn default() -> Self {
41 CratesSource::new()
42 }
43}
44
45impl TrendSource for CratesSource {
46 fn name(&self) -> &'static str {
47 "cratesio"
48 }
49
50 fn collect(
51 &self,
52 ctx: &CollectCtx,
53 ) -> impl std::future::Future<Output = Result<CollectReport>> {
54 let ctx = ctx.clone();
55 async move {
56 let now = unix_seconds(ctx.clock.as_ref());
57 let mut total = 0usize;
58 for page in 1..=ctx.pages {
59 let params = CratesQuery {
60 sort: "downloads",
61 per_page: PER_PAGE,
62 page,
63 };
64 let url = format!("{CRATES_API}/crates");
65 let response: CratesResponse =
66 self.client.get(url).query(¶ms).json_response().await?;
67 let count = response.crates.len();
68 for krate in response.crates {
69 let observation = Observation {
70 ts: now,
71 score: krate.downloads,
72 extra1: krate.recent_downloads.map(|f| f as i64).unwrap_or(0),
73 extra2: 0,
74 };
75 let subject = SubjectRecord::from(krate);
76 let subject_id = ctx.store.upsert_subject(&subject)?;
77 ctx.store.insert_observation(subject_id, &observation)?;
78 total += 1;
79 }
80 if count < PER_PAGE as usize || page == ctx.pages {
81 break;
82 }
83 tokio::time::sleep(CRATES_PACING).await;
84 }
85 ctx.store
86 .record_run(now, self.name(), i64::from(ctx.pages), total as i64)?;
87 Ok(CollectReport {
88 source: self.name(),
89 items: total,
90 pages: ctx.pages as usize,
91 })
92 }
93 }
94}
95
96#[derive(Debug, Serialize)]
97struct CratesQuery<'a> {
98 sort: &'a str,
99 per_page: u32,
100 page: u32,
101}
102
103#[derive(Debug, Deserialize)]
104struct CratesResponse {
105 #[serde(default)]
106 crates: Vec<CrateJson>,
107}
108
109#[derive(Debug, Clone, Deserialize)]
111#[allow(dead_code)] pub(crate) struct CrateJson {
113 #[serde(default)]
114 pub(crate) name: String,
115 #[serde(default)]
116 pub(crate) description: Option<String>,
117 #[serde(default)]
118 pub(crate) downloads: i64,
119 #[serde(default)]
120 pub(crate) recent_downloads: Option<f64>,
121 #[serde(default)]
122 pub(crate) max_version: Option<String>,
123 #[serde(default)]
124 pub(crate) created_at: Option<String>,
125 #[serde(default)]
126 pub(crate) keywords: Option<Vec<String>>,
127}
128
129impl From<CrateJson> for SubjectRecord {
130 fn from(json: CrateJson) -> Self {
131 let url = format!("https://crates.io/crates/{}", json.name);
132 SubjectRecord {
133 kind: SourceKind::Crate,
134 key: json.name,
135 title: json.description,
136 lang: None,
137 url: Some(url),
138 topics: json.keywords.unwrap_or_default(),
139 created_at: json.created_at,
140 }
141 }
142}
143
144pub fn parse_crates_json(payload: &str) -> Result<Vec<SubjectRecord>> {
149 let response: CratesResponse = serde_json::from_str(payload)
150 .map_err(|e| crate::error::TrendError::Config(format!("invalid crates payload: {e}")))?;
151 Ok(response
152 .crates
153 .into_iter()
154 .map(SubjectRecord::from)
155 .collect())
156}
157
158#[cfg(test)]
159mod tests {
160 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
161
162 use super::*;
163
164 const SAMPLE: &str = r#"{
165 "crates": [
166 {
167 "name": "serde",
168 "description": "A serialization framework",
169 "downloads": 300000000,
170 "recent_downloads": 12345678.0,
171 "max_version": "1.0.220",
172 "created_at": "2016-01-05T00:00:00Z",
173 "keywords": ["serialization", "no_std"]
174 },
175 {
176 "name": "bare-crate",
177 "downloads": 99
178 }
179 ],
180 "meta": { "total": 2 }
181 }"#;
182
183 #[test]
184 fn parse_extracts_crates_with_defaults() {
185 let subjects = parse_crates_json(SAMPLE).unwrap();
186 assert_eq!(subjects.len(), 2);
187 assert_eq!(subjects[0].kind, SourceKind::Crate);
188 assert_eq!(subjects[0].key, "serde");
189 assert_eq!(
190 subjects[0].title.as_deref(),
191 Some("A serialization framework")
192 );
193 assert_eq!(
194 subjects[0].topics,
195 vec!["serialization".to_owned(), "no_std".to_owned()]
196 );
197 assert_eq!(subjects[1].topics, Vec::<String>::new());
199 assert_eq!(subjects[1].title, None);
200 }
201
202 #[test]
203 fn parse_rejects_garbage() {
204 assert!(parse_crates_json("<html>").is_err());
205 }
206}