1use std::collections::{BTreeMap, HashMap};
13use std::fs;
14use std::path::Path;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use anyhow::{Context, Result};
18use rusqlite::{Connection, OptionalExtension, params};
19
20pub const HALF_LIFE: f64 = 14.0 * 24.0 * 60.0 * 60.0;
22
23pub const NEW_ENTRY_BONUS: f64 = 3.0;
29
30const BUSY_TIMEOUT: Duration = Duration::from_secs(3);
32
33const SCHEMA: &str = "
34CREATE TABLE IF NOT EXISTS usage (
35 id TEXT PRIMARY KEY,
36 score REAL NOT NULL,
37 last_used_at INTEGER NOT NULL,
38 pinned INTEGER NOT NULL DEFAULT 0
39);
40
41CREATE TABLE IF NOT EXISTS param_values (
42 id TEXT NOT NULL,
43 name TEXT NOT NULL,
44 value TEXT NOT NULL,
45 PRIMARY KEY (id, name)
46);
47";
48
49#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct Score {
52 pub value: f64,
53 pub pinned: bool,
54}
55
56pub fn now() -> i64 {
58 SystemTime::now()
59 .duration_since(UNIX_EPOCH)
60 .map(|d| d.as_secs() as i64)
61 .unwrap_or_default()
62}
63
64pub fn decay(score: f64, elapsed: i64) -> f64 {
66 if elapsed <= 0 {
67 return score;
68 }
69 score * 0.5f64.powf(elapsed as f64 / HALF_LIFE)
70}
71
72pub struct Stats {
73 conn: Connection,
74}
75
76impl Stats {
77 pub fn open(path: &Path) -> Result<Self> {
79 if let Some(parent) = path.parent() {
80 fs::create_dir_all(parent)
81 .with_context(|| format!("failed to create {}", parent.display()))?;
82 }
83
84 let conn =
85 Connection::open(path).with_context(|| format!("failed to open {}", path.display()))?;
86 Self::prepare(conn)
87 }
88
89 pub fn in_memory() -> Result<Self> {
90 Self::prepare(Connection::open_in_memory()?)
91 }
92
93 fn prepare(conn: Connection) -> Result<Self> {
94 conn.pragma_update_and_check(None, "journal_mode", "WAL", |_| Ok(()))
97 .context("failed to enable write-ahead logging")?;
98 conn.busy_timeout(BUSY_TIMEOUT)?;
99 conn.execute_batch(SCHEMA)
100 .context("failed to initialise the statistics schema")?;
101
102 Ok(Self { conn })
103 }
104
105 pub fn record_use(&self, id: &str, now: i64) -> Result<()> {
107 let previous = self.raw_score(id)?;
108 let score = match previous {
109 Some((score, last_used_at)) => decay(score, now - last_used_at) + 1.0,
110 None => 1.0,
111 };
112 self.write_score(id, score, now)
113 }
114
115 pub fn record_new(&self, id: &str, now: i64) -> Result<()> {
117 self.write_score(id, NEW_ENTRY_BONUS, now)
118 }
119
120 pub fn scores(&self, now: i64) -> Result<HashMap<String, Score>> {
125 let mut statement = self
126 .conn
127 .prepare("SELECT id, score, last_used_at, pinned FROM usage")?;
128
129 let rows = statement.query_map([], |row| {
130 let id: String = row.get(0)?;
131 let score: f64 = row.get(1)?;
132 let last_used_at: i64 = row.get(2)?;
133 let pinned: bool = row.get(3)?;
134 Ok((
135 id,
136 Score {
137 value: decay(score, now - last_used_at),
138 pinned,
139 },
140 ))
141 })?;
142
143 Ok(rows.collect::<rusqlite::Result<HashMap<_, _>>>()?)
144 }
145
146 pub fn set_pinned(&self, id: &str, pinned: bool, now: i64) -> Result<()> {
147 self.conn.execute(
148 "INSERT INTO usage (id, score, last_used_at, pinned) VALUES (?1, 0.0, ?2, ?3)
149 ON CONFLICT(id) DO UPDATE SET pinned = ?3",
150 params![id, now, pinned],
151 )?;
152 Ok(())
153 }
154
155 pub fn remember_param(&self, id: &str, name: &str, value: &str) -> Result<()> {
158 self.conn.execute(
159 "INSERT INTO param_values (id, name, value) VALUES (?1, ?2, ?3)
160 ON CONFLICT(id, name) DO UPDATE SET value = ?3",
161 params![id, name, value],
162 )?;
163 Ok(())
164 }
165
166 pub fn last_params(&self, id: &str) -> Result<BTreeMap<String, String>> {
167 let mut statement = self
168 .conn
169 .prepare("SELECT name, value FROM param_values WHERE id = ?1")?;
170 let rows = statement.query_map([id], |row| Ok((row.get(0)?, row.get(1)?)))?;
171 Ok(rows.collect::<rusqlite::Result<BTreeMap<_, _>>>()?)
172 }
173
174 pub fn forget(&self, id: &str) -> Result<()> {
176 self.conn.execute("DELETE FROM usage WHERE id = ?1", [id])?;
177 self.conn
178 .execute("DELETE FROM param_values WHERE id = ?1", [id])?;
179 Ok(())
180 }
181
182 fn raw_score(&self, id: &str) -> Result<Option<(f64, i64)>> {
183 Ok(self
184 .conn
185 .query_row(
186 "SELECT score, last_used_at FROM usage WHERE id = ?1",
187 [id],
188 |row| Ok((row.get(0)?, row.get(1)?)),
189 )
190 .optional()?)
191 }
192
193 fn write_score(&self, id: &str, score: f64, now: i64) -> Result<()> {
194 self.conn.execute(
195 "INSERT INTO usage (id, score, last_used_at) VALUES (?1, ?2, ?3)
196 ON CONFLICT(id) DO UPDATE SET score = ?2, last_used_at = ?3",
197 params![id, score, now],
198 )?;
199 Ok(())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 const DAY: i64 = 24 * 60 * 60;
208
209 fn score_of(stats: &Stats, id: &str, now: i64) -> f64 {
210 stats.scores(now).unwrap()[id].value
211 }
212
213 #[test]
214 fn a_score_halves_over_one_half_life() {
215 let halved = decay(4.0, HALF_LIFE as i64);
216 assert!((halved - 2.0).abs() < 1e-6, "expected 2.0, got {halved}");
217 }
218
219 #[test]
220 fn a_score_does_not_grow_backwards_in_time() {
221 assert_eq!(decay(4.0, 0), 4.0);
222 assert_eq!(decay(4.0, -DAY), 4.0);
223 }
224
225 #[test]
226 fn first_use_scores_one_point() {
227 let stats = Stats::in_memory().unwrap();
228 stats.record_use("git.log", 0).unwrap();
229 assert_eq!(score_of(&stats, "git.log", 0), 1.0);
230 }
231
232 #[test]
233 fn repeated_use_accumulates_on_top_of_decay() {
234 let stats = Stats::in_memory().unwrap();
235 let half_life = HALF_LIFE as i64;
236
237 stats.record_use("git.log", 0).unwrap();
238 stats.record_use("git.log", half_life).unwrap();
239
240 let score = score_of(&stats, "git.log", half_life);
242 assert!((score - 1.5).abs() < 1e-6, "expected 1.5, got {score}");
243 }
244
245 #[test]
246 fn a_daily_habit_outranks_a_forgotten_burst() {
247 let stats = Stats::in_memory().unwrap();
248 let now = 60 * DAY;
249
250 for day in 0..60 {
251 stats.record_use("daily", day * DAY).unwrap();
252 }
253 for _ in 0..20 {
254 stats.record_use("burst", 0).unwrap();
255 }
256
257 assert!(score_of(&stats, "daily", now) > score_of(&stats, "burst", now));
258 }
259
260 #[test]
261 fn a_new_entry_starts_ahead_but_slides_without_use() {
262 let stats = Stats::in_memory().unwrap();
263
264 stats.record_new("fresh", 0).unwrap();
265 stats.record_use("occasional", 0).unwrap();
266 assert!(score_of(&stats, "fresh", 0) > score_of(&stats, "occasional", 0));
267
268 let later = 60 * DAY;
270 stats.record_use("occasional", 30 * DAY).unwrap();
271 stats.record_use("occasional", 55 * DAY).unwrap();
272 assert!(score_of(&stats, "fresh", later) < score_of(&stats, "occasional", later));
273 }
274
275 #[test]
276 fn an_unused_entry_has_no_score() {
277 let stats = Stats::in_memory().unwrap();
278 stats.record_use("git.log", 0).unwrap();
279 assert!(!stats.scores(0).unwrap().contains_key("docker.ps"));
280 }
281
282 #[test]
283 fn pinning_survives_later_use() {
284 let stats = Stats::in_memory().unwrap();
285 stats.set_pinned("git.log", true, 0).unwrap();
286 stats.record_use("git.log", DAY).unwrap();
287
288 let score = stats.scores(DAY).unwrap()["git.log"];
289 assert!(score.pinned);
290 assert_eq!(score.value, 1.0);
291 }
292
293 #[test]
294 fn pinning_an_unused_entry_does_not_invent_a_score() {
295 let stats = Stats::in_memory().unwrap();
296 stats.set_pinned("git.log", true, 0).unwrap();
297 assert_eq!(score_of(&stats, "git.log", 0), 0.0);
298 }
299
300 #[test]
301 fn remembered_parameters_round_trip_and_overwrite() {
302 let stats = Stats::in_memory().unwrap();
303 stats
304 .remember_param("k8s.logs", "namespace", "dev")
305 .unwrap();
306 stats.remember_param("k8s.logs", "pod", "api-0").unwrap();
307 stats
308 .remember_param("k8s.logs", "namespace", "prod")
309 .unwrap();
310
311 let values = stats.last_params("k8s.logs").unwrap();
312 assert_eq!(values["namespace"], "prod");
313 assert_eq!(values["pod"], "api-0");
314 assert!(stats.last_params("other").unwrap().is_empty());
315 }
316
317 #[test]
318 fn two_connections_can_write_the_same_database() {
319 let path = std::env::temp_dir().join(format!("lore-stats-{}.db", std::process::id()));
320 let _ = fs::remove_file(&path);
321
322 let first = Stats::open(&path).unwrap();
323 let second = Stats::open(&path).unwrap();
324
325 first.record_use("git.log", 0).unwrap();
326 second.record_use("docker.ps", 0).unwrap();
327 first.record_use("docker.ps", DAY).unwrap();
328
329 let scores = second.scores(DAY).unwrap();
332 assert_eq!(scores.len(), 2);
333 assert!((scores["docker.ps"].value - (decay(1.0, DAY) + 1.0)).abs() < 1e-9);
334 assert_eq!(scores["git.log"].value, decay(1.0, DAY));
335
336 drop(first);
337 drop(second);
338 let _ = fs::remove_file(&path);
339 }
340
341 #[test]
342 fn forgetting_an_entry_clears_its_score_and_parameters() {
343 let stats = Stats::in_memory().unwrap();
344 stats.record_use("gone", 0).unwrap();
345 stats.remember_param("gone", "path", "/tmp").unwrap();
346
347 stats.forget("gone").unwrap();
348
349 assert!(stats.scores(0).unwrap().is_empty());
350 assert!(stats.last_params("gone").unwrap().is_empty());
351 }
352}