1use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::{Context, Result};
13
14static HISTORY_SEQ: AtomicU64 = AtomicU64::new(0);
15
16#[derive(Clone, Debug)]
18pub struct HistoryItem {
19 pub url: String,
20 pub title: String,
21 pub visited_at_ms: u64,
22}
23
24impl HistoryItem {
25 fn to_bytes(&self) -> Vec<u8> {
26 let ts_bytes = self.visited_at_ms.to_le_bytes();
27 let title_bytes = self.title.as_bytes();
28 let title_len = (title_bytes.len() as u32).to_le_bytes();
29 let url_bytes = self.url.as_bytes();
30 let url_len = (url_bytes.len() as u32).to_le_bytes();
31
32 let mut buf = Vec::with_capacity(8 + 4 + title_bytes.len() + 4 + url_bytes.len());
33 buf.extend_from_slice(&ts_bytes);
34 buf.extend_from_slice(&title_len);
35 buf.extend_from_slice(title_bytes);
36 buf.extend_from_slice(&url_len);
37 buf.extend_from_slice(url_bytes);
38 buf
39 }
40
41 fn from_bytes(data: &[u8]) -> Option<Self> {
42 if data.len() < 16 {
43 return None;
44 }
45 let visited_at_ms = u64::from_le_bytes(data[0..8].try_into().ok()?);
46 let title_len = u32::from_le_bytes(data[8..12].try_into().ok()?) as usize;
47 if data.len() < 12 + title_len + 4 {
48 return None;
49 }
50 let title = String::from_utf8(data[12..12 + title_len].to_vec()).ok()?;
51 let url_off = 12 + title_len;
52 let url_len = u32::from_le_bytes(data[url_off..url_off + 4].try_into().ok()?) as usize;
53 if data.len() < url_off + 4 + url_len {
54 return None;
55 }
56 let url = String::from_utf8(data[url_off + 4..url_off + 4 + url_len].to_vec()).ok()?;
57 Some(Self {
58 url,
59 title,
60 visited_at_ms,
61 })
62 }
63}
64
65#[derive(Clone)]
67pub struct HistoryStore {
68 tree: sled::Tree,
69}
70
71impl HistoryStore {
72 pub fn open(db: &sled::Db) -> Result<Self> {
73 let tree = db
74 .open_tree("history")
75 .context("failed to open history tree")?;
76 Ok(Self { tree })
77 }
78
79 pub fn record(&self, url: &str, title: &str) -> Result<()> {
81 let ts = now_ms();
82 let seq = HISTORY_SEQ.fetch_add(1, Ordering::Relaxed);
83 let item = HistoryItem {
84 url: url.to_string(),
85 title: title.to_string(),
86 visited_at_ms: ts,
87 };
88 let mut key = Vec::with_capacity(16);
89 key.extend_from_slice(&ts.to_be_bytes());
90 key.extend_from_slice(&seq.to_be_bytes());
91 self.tree
92 .insert(key, item.to_bytes())
93 .context("failed to insert history entry")?;
94 Ok(())
95 }
96
97 pub fn list_all(&self) -> Vec<(Vec<u8>, HistoryItem)> {
100 let mut items = Vec::new();
101 for entry in self.tree.iter().flatten() {
102 let (key, val) = entry;
103 if let Some(item) = HistoryItem::from_bytes(&val) {
104 items.push((key.to_vec(), item));
105 }
106 }
107 items.reverse();
108 items
109 }
110
111 pub fn remove_by_key(&self, key: &[u8]) -> Result<()> {
113 self.tree
114 .remove(key)
115 .context("failed to remove history entry")?;
116 Ok(())
117 }
118
119 pub fn clear(&self) -> Result<()> {
121 self.tree.clear().context("failed to clear history")?;
122 Ok(())
123 }
124}
125
126pub type SharedHistoryStore = Arc<Mutex<Option<HistoryStore>>>;
127
128fn now_ms() -> u64 {
129 SystemTime::now()
130 .duration_since(UNIX_EPOCH)
131 .unwrap_or_default()
132 .as_millis() as u64
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use tempfile::tempdir;
139
140 fn test_store() -> HistoryStore {
141 let dir = tempdir().unwrap();
142 let db = sled::open(dir.path()).unwrap();
143 HistoryStore::open(&db).unwrap()
144 }
145
146 #[test]
147 fn record_and_list() {
148 let store = test_store();
149 store.record("https://a.com/app.wasm", "App A").unwrap();
150 store.record("https://b.com/app.wasm", "App B").unwrap();
151 let all = store.list_all();
152 assert_eq!(all.len(), 2);
153 assert_eq!(all[0].1.url, "https://b.com/app.wasm");
154 assert_eq!(all[1].1.url, "https://a.com/app.wasm");
155 }
156
157 #[test]
158 fn duplicate_urls_create_separate_entries() {
159 let store = test_store();
160 store.record("https://a.com/app.wasm", "A").unwrap();
161 store.record("https://a.com/app.wasm", "A").unwrap();
162 let all = store.list_all();
163 assert_eq!(all.len(), 2);
164 }
165
166 #[test]
167 fn remove_single_entry() {
168 let store = test_store();
169 store.record("https://a.com/app.wasm", "A").unwrap();
170 store.record("https://b.com/app.wasm", "B").unwrap();
171 let all = store.list_all();
172 assert_eq!(all.len(), 2);
173 store.remove_by_key(&all[0].0).unwrap();
174 let remaining = store.list_all();
175 assert_eq!(remaining.len(), 1);
176 assert_eq!(remaining[0].1.url, "https://a.com/app.wasm");
177 }
178
179 #[test]
180 fn clear_all() {
181 let store = test_store();
182 store.record("https://a.com/app.wasm", "A").unwrap();
183 store.record("https://b.com/app.wasm", "B").unwrap();
184 store.clear().unwrap();
185 assert_eq!(store.list_all().len(), 0);
186 }
187}