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}