Skip to main content

trendkit_core/
model.rs

1//! Shared data model for snapshot collection and reporting.
2//!
3//! One subject/observation pair serves every source: a *subject* is a
4//! thing being tracked (repo, HN story, crate), an *observation* is one
5//! point-in-time reading of its headline counter. Velocity is always
6//! `Δscore / Δdays` over the window; what "score" means is documented
7//! per [`SourceKind`].
8
9use std::fmt;
10
11/// What kind of thing a subject is, and what its score counts.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum SourceKind {
14    /// GitHub repository; score = stars.
15    GithubRepo,
16    /// Hacker News story; score = points.
17    HnStory,
18    /// crates.io crate; score = total downloads.
19    Crate,
20}
21
22impl SourceKind {
23    /// Stable string stored in the database.
24    #[must_use]
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            SourceKind::GithubRepo => "github_repo",
28            SourceKind::HnStory => "hn_story",
29            SourceKind::Crate => "crate",
30        }
31    }
32
33    /// Short tag for report tables.
34    #[must_use]
35    pub const fn tag(self) -> &'static str {
36        match self {
37            SourceKind::GithubRepo => "GH",
38            SourceKind::HnStory => "HN",
39            SourceKind::Crate => "CR",
40        }
41    }
42
43    /// Parse the database string back.
44    #[must_use]
45    pub fn parse(s: &str) -> Option<SourceKind> {
46        match s {
47            "github_repo" => Some(SourceKind::GithubRepo),
48            "hn_story" => Some(SourceKind::HnStory),
49            "crate" => Some(SourceKind::Crate),
50            _ => None,
51        }
52    }
53}
54
55impl fmt::Display for SourceKind {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str(self.as_str())
58    }
59}
60
61/// A thing being tracked, stored once per `(kind, key)` and enriched on
62/// later scans. `title` carries the human-readable line (repo
63/// description, story title, crate description).
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SubjectRecord {
66    /// Subject type.
67    pub kind: SourceKind,
68    /// Source-stable identifier: `owner/repo`, HN object id, crate name.
69    pub key: String,
70    /// Human-readable line.
71    pub title: Option<String>,
72    /// Primary language (GitHub only today).
73    pub lang: Option<String>,
74    /// Canonical URL.
75    pub url: Option<String>,
76    /// Topics/keywords, lowercased by the source.
77    pub topics: Vec<String>,
78    /// Source creation timestamp (RFC 3339 or epoch), when available.
79    pub created_at: Option<String>,
80}
81
82/// One point-in-time reading of a subject's counters.
83///
84/// `extra1`/`extra2` are source-defined (GitHub: forks/watchers, HN:
85/// comments, crates.io: recent downloads) — velocity only ever uses
86/// `score`.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Observation {
89    /// Unix seconds at reading time.
90    pub ts: i64,
91    /// Headline counter (stars / points / downloads).
92    pub score: i64,
93    /// Source-defined secondary counter.
94    pub extra1: i64,
95    /// Source-defined tertiary counter.
96    pub extra2: i64,
97}
98
99/// One row from a user's repository listing, used for the personal profile.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct RepoListed {
102    /// GitHub full name (`owner/repo`).
103    pub full_name: String,
104    /// Primary language, if any.
105    pub language: Option<String>,
106    /// Repository topics, lowercased by GitHub.
107    pub topics: Vec<String>,
108    /// Short description, if any.
109    pub description: Option<String>,
110    /// True for forks (excluded from profile weighting).
111    pub is_fork: bool,
112    /// Last push timestamp as returned by GitHub, if present.
113    pub pushed_at: Option<String>,
114    /// Star count at listing time.
115    pub stars: i64,
116}
117
118/// A velocity computation for one subject over a snapshot window.
119#[derive(Debug, Clone, PartialEq)]
120pub struct VelocityRow {
121    /// Subject type.
122    pub kind: SourceKind,
123    /// Source-stable identifier.
124    pub key: String,
125    /// Human-readable line, if any.
126    pub title: Option<String>,
127    /// Primary language, if any.
128    pub lang: Option<String>,
129    /// Canonical URL, if any.
130    pub url: Option<String>,
131    /// Topics attached to the subject.
132    pub topics: Vec<String>,
133    /// Most recent observed score.
134    pub score_now: i64,
135    /// Earliest observed score inside the window.
136    pub score_prev: i64,
137    /// Seconds between the two observations.
138    pub window_secs: i64,
139    /// Score gained per day over the window (see [`crate::score`]).
140    pub velocity_per_day: f64,
141}
142
143#[cfg(test)]
144mod tests {
145    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
146
147    use super::*;
148
149    #[test]
150    fn source_kind_roundtrips() {
151        for kind in [
152            SourceKind::GithubRepo,
153            SourceKind::HnStory,
154            SourceKind::Crate,
155        ] {
156            assert_eq!(SourceKind::parse(kind.as_str()), Some(kind));
157        }
158        assert_eq!(SourceKind::parse("nope"), None);
159    }
160
161    #[test]
162    fn tags_are_distinct() {
163        let tags = [
164            SourceKind::GithubRepo.tag(),
165            SourceKind::HnStory.tag(),
166            SourceKind::Crate.tag(),
167        ];
168        assert_eq!(tags.len(), 3);
169        assert_ne!(tags[0], tags[1]);
170        assert_ne!(tags[1], tags[2]);
171    }
172}