Skip to main content

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}