1use anyhow::Result;
15
16use crate::registry::{db, now_epoch_s, Registry};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TabRow {
21 pub browser_name: String,
22 pub name: String,
23 pub target_id: String,
24 pub last_url: String,
25 pub last_used_at_epoch_s: i64,
26 pub daemon_created: bool,
27}
28
29impl Registry {
30 pub fn tab_get(&self, browser_name: &str, name: &str) -> Result<Option<TabRow>> {
32 db::query_optional(
33 &self.conn,
34 "SELECT browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created \
35 FROM tabs WHERE browser_name = ?1 AND name = ?2",
36 rusqlite::params![browser_name, name],
37 row_to_tab,
38 )
39 }
40
41 pub fn tabs_list_for(&self, browser_name: &str) -> Result<Vec<TabRow>> {
43 db::query_vec(
44 &self.conn,
45 "SELECT browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created \
46 FROM tabs WHERE browser_name = ?1 ORDER BY name ASC",
47 [browser_name],
48 row_to_tab,
49 )
50 }
51
52 pub fn tab_upsert(
54 &self,
55 browser_name: &str,
56 name: &str,
57 target_id: &str,
58 last_url: &str,
59 daemon_created: bool,
60 ) -> Result<()> {
61 let now = now_epoch_s();
62 db::execute(
63 &self.conn,
64 "INSERT INTO tabs \
65 (browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created) \
66 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
67 ON CONFLICT(browser_name, name) DO UPDATE SET \
68 target_id = excluded.target_id, \
69 last_url = excluded.last_url, \
70 last_used_at_epoch_s = excluded.last_used_at_epoch_s, \
71 daemon_created = excluded.daemon_created",
72 rusqlite::params![
73 browser_name,
74 name,
75 target_id,
76 last_url,
77 now,
78 daemon_created as i32,
79 ],
80 || format!("upsert tab {browser_name}/{name}"),
81 )
82 }
83
84 pub fn tab_touch(&self, browser_name: &str, name: &str) -> Result<()> {
86 let now = now_epoch_s();
87 db::execute_bare(
88 &self.conn,
89 "UPDATE tabs SET last_used_at_epoch_s = ?1 \
90 WHERE browser_name = ?2 AND name = ?3",
91 rusqlite::params![now, browser_name, name],
92 )
93 }
94
95 pub fn tab_set_url(&self, browser_name: &str, name: &str, url: &str) -> Result<()> {
97 let now = now_epoch_s();
98 db::execute_bare(
99 &self.conn,
100 "UPDATE tabs SET last_url = ?1, last_used_at_epoch_s = ?2 \
101 WHERE browser_name = ?3 AND name = ?4",
102 rusqlite::params![url, now, browser_name, name],
103 )
104 }
105
106 pub fn tab_delete(&self, browser_name: &str, name: &str) -> Result<()> {
108 db::execute_bare(
109 &self.conn,
110 "DELETE FROM tabs WHERE browser_name = ?1 AND name = ?2",
111 rusqlite::params![browser_name, name],
112 )
113 }
114
115 pub fn tabs_count_daemon_created(&self, browser_name: &str) -> Result<usize> {
118 let n: i64 = self.conn.query_row(
119 "SELECT COUNT(*) FROM tabs \
120 WHERE browser_name = ?1 AND daemon_created = 1",
121 [browser_name],
122 |r| r.get(0),
123 )?;
124 Ok(n as usize)
125 }
126
127 pub fn tabs_lru_daemon_created(&self, browser_name: &str) -> Result<Option<TabRow>> {
130 db::query_optional(
131 &self.conn,
132 "SELECT browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created \
133 FROM tabs \
134 WHERE browser_name = ?1 AND daemon_created = 1 \
135 ORDER BY last_used_at_epoch_s ASC, name ASC \
136 LIMIT 1",
137 [browser_name],
138 row_to_tab,
139 )
140 }
141}
142
143fn row_to_tab(r: &rusqlite::Row<'_>) -> Result<TabRow> {
144 Ok(TabRow {
145 browser_name: r.get(0)?,
146 name: r.get(1)?,
147 target_id: r.get(2)?,
148 last_url: r.get(3)?,
149 last_used_at_epoch_s: r.get(4)?,
150 daemon_created: r.get::<_, i64>(5)? != 0,
151 })
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn fresh() -> Registry {
159 Registry::open_in_memory().unwrap()
160 }
161
162 #[test]
163 fn upsert_then_get_round_trip() {
164 let reg = fresh();
165 reg.tab_upsert("brave", "scrape-cart", "T1", "https://shop", true)
166 .unwrap();
167 let got = reg.tab_get("brave", "scrape-cart").unwrap().unwrap();
168 assert_eq!(got.target_id, "T1");
169 assert_eq!(got.last_url, "https://shop");
170 assert!(got.daemon_created);
171 }
172
173 #[test]
174 fn upsert_replaces_target_id_and_url() {
175 let reg = fresh();
176 reg.tab_upsert("b", "n", "T1", "u1", true).unwrap();
177 reg.tab_upsert("b", "n", "T2", "u2", false).unwrap();
178 let got = reg.tab_get("b", "n").unwrap().unwrap();
179 assert_eq!(got.target_id, "T2");
180 assert_eq!(got.last_url, "u2");
181 assert!(!got.daemon_created, "provenance flips on upsert");
182 }
183
184 #[test]
185 fn list_returns_rows_sorted_by_name() {
186 let reg = fresh();
187 reg.tab_upsert("b", "zebra", "T1", "", true).unwrap();
188 reg.tab_upsert("b", "alpha", "T2", "", true).unwrap();
189 reg.tab_upsert("b", "monk", "T3", "", true).unwrap();
190 let names: Vec<String> = reg
191 .tabs_list_for("b")
192 .unwrap()
193 .into_iter()
194 .map(|t| t.name)
195 .collect();
196 assert_eq!(names, vec!["alpha", "monk", "zebra"]);
197 }
198
199 #[test]
200 fn list_is_scoped_per_browser() {
201 let reg = fresh();
202 reg.tab_upsert("a", "x", "T1", "", true).unwrap();
203 reg.tab_upsert("b", "y", "T2", "", true).unwrap();
204 assert_eq!(reg.tabs_list_for("a").unwrap().len(), 1);
205 assert_eq!(reg.tabs_list_for("b").unwrap().len(), 1);
206 }
207
208 #[test]
209 fn count_excludes_user_created() {
210 let reg = fresh();
211 reg.tab_upsert("b", "agent1", "T1", "", true).unwrap();
212 reg.tab_upsert("b", "agent2", "T2", "", true).unwrap();
213 reg.tab_upsert("b", "user-tab", "T3", "", false).unwrap();
214 assert_eq!(reg.tabs_count_daemon_created("b").unwrap(), 2);
215 }
216
217 #[test]
218 fn lru_picks_oldest_daemon_created() {
219 let reg = fresh();
220 reg.tab_upsert("b", "old-daemon", "T1", "", true).unwrap();
221 std::thread::sleep(std::time::Duration::from_millis(1100));
222 reg.tab_upsert("b", "new-daemon", "T2", "", true).unwrap();
223 reg.tab_upsert("b", "user-tab", "T3", "", false).unwrap();
224 let lru = reg.tabs_lru_daemon_created("b").unwrap().unwrap();
225 assert_eq!(lru.name, "old-daemon", "LRU honors last_used_at order");
226 }
227
228 #[test]
229 fn delete_removes_only_named_row() {
230 let reg = fresh();
231 reg.tab_upsert("b", "x", "T1", "", true).unwrap();
232 reg.tab_upsert("b", "y", "T2", "", true).unwrap();
233 reg.tab_delete("b", "x").unwrap();
234 assert!(reg.tab_get("b", "x").unwrap().is_none());
235 assert!(reg.tab_get("b", "y").unwrap().is_some());
236 }
237
238 #[test]
239 fn set_url_updates_just_the_url() {
240 let reg = fresh();
241 reg.tab_upsert("b", "n", "T1", "u1", true).unwrap();
242 reg.tab_set_url("b", "n", "u2").unwrap();
243 let got = reg.tab_get("b", "n").unwrap().unwrap();
244 assert_eq!(got.target_id, "T1");
245 assert_eq!(got.last_url, "u2");
246 }
247}