signalkit_core/source.rs
1//! Source trait and collection context.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use clock_kit::Clock;
7
8use crate::error::Result;
9use crate::store::Store;
10
11/// Everything a [`TrendSource`] needs for one collection pass.
12#[derive(Clone)]
13pub struct CollectCtx {
14 /// Injected time source (SystemClock in production, MockClock in tests).
15 pub clock: Arc<dyn Clock + Send + Sync>,
16 /// Snapshot store to write into.
17 pub store: Arc<Store>,
18 /// How many pages to pull from paginated endpoints.
19 pub pages: u32,
20 /// How far back "recent" reaches, in days.
21 pub window_days: u32,
22}
23
24impl std::fmt::Debug for CollectCtx {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 f.debug_struct("CollectCtx")
27 .field("pages", &self.pages)
28 .field("window_days", &self.window_days)
29 .finish_non_exhaustive()
30 }
31}
32
33/// Summary of one completed collection pass.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CollectReport {
36 /// Source name (`"github"`, later `"hn"`, `"cratesio"`).
37 pub source: &'static str,
38 /// Observations written.
39 pub items: usize,
40 /// Pages pulled.
41 pub pages: usize,
42}
43
44/// A named trend data source.
45///
46/// Object safety is deliberately not a goal: sources are constructed
47/// concretely by the CLI and dispatched by an enum there, which keeps the
48/// trait compatible with async-fn-in-trait.
49pub trait TrendSource {
50 /// Stable source identifier used in `collect_runs`.
51 fn name(&self) -> &'static str;
52
53 /// Run one collection pass, writing snapshots into `ctx.store`.
54 ///
55 /// The returned future is deliberately **not** `Send`: collection is
56 /// sequential by design (API pacing) and the SQLite connection must
57 /// stay confined to the collecting task.
58 fn collect(&self, ctx: &CollectCtx)
59 -> impl std::future::Future<Output = Result<CollectReport>>;
60}
61
62/// Minimum spacing applied between paged requests to a rate-limited
63/// endpoint (shared by sources; currently only the GitHub Search API at
64/// 30 req/min authenticated needs it).
65pub const SEARCH_PACING: Duration = Duration::from_millis(2_200);
66
67#[cfg(test)]
68mod tests {
69 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
70
71 use super::*;
72
73 #[test]
74 fn pacing_meets_github_search_budget() {
75 // Authenticated search budget is 30 req/min = one per 2s.
76 assert!(SEARCH_PACING >= Duration::from_secs(2));
77 }
78}