1use rusqlite::params;
5
6use crate::error::DbError;
7use crate::sqlite::Database;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct PaperState {
11 pub paper_id: String,
12 pub reader: String,
13 pub starred: bool,
14 pub to_read: bool,
15 pub read_at: Option<String>,
17}
18
19#[derive(Clone)]
20pub struct SqlitePaperStateRepository {
21 db: Database,
22}
23
24impl SqlitePaperStateRepository {
25 pub fn new(db: Database) -> Self {
26 Self { db }
27 }
28
29 pub fn get(&self, paper_id: &str, reader: &str) -> Result<Option<PaperState>, DbError> {
31 let conn = self.db.conn()?;
32 let mut stmt = conn.prepare(
33 "SELECT paper_id, reader, starred, to_read, read_at
34 FROM paper_state
35 WHERE paper_id = ?1 AND reader = ?2",
36 )?;
37 let mut rows = stmt.query(params![paper_id, reader])?;
38 if let Some(row) = rows.next()? {
39 Ok(Some(PaperState {
40 paper_id: row.get(0)?,
41 reader: row.get(1)?,
42 starred: row.get::<_, i64>(2)? != 0,
43 to_read: row.get::<_, i64>(3)? != 0,
44 read_at: row.get::<_, Option<String>>(4)?,
45 }))
46 } else {
47 Ok(None)
48 }
49 }
50
51 pub fn set(&self, state: &PaperState) -> Result<(), DbError> {
53 let conn = self.db.conn()?;
54 let now = chrono::Utc::now().to_rfc3339();
55 conn.execute(
56 "INSERT INTO paper_state (paper_id, reader, starred, to_read, read_at, updated_at)
57 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
58 ON CONFLICT(paper_id, reader) DO UPDATE SET
59 starred = excluded.starred,
60 to_read = excluded.to_read,
61 read_at = excluded.read_at,
62 updated_at = excluded.updated_at",
63 params![
64 state.paper_id,
65 state.reader,
66 i64::from(state.starred),
67 i64::from(state.to_read),
68 state.read_at,
69 now,
70 ],
71 )?;
72 Ok(())
73 }
74
75 pub fn toggle_starred(&self, paper_id: &str, reader: &str) -> Result<bool, DbError> {
77 let existing = self.get(paper_id, reader)?;
78 let new_state = match existing {
79 Some(mut s) => {
80 s.starred = !s.starred;
81 s
82 }
83 None => PaperState {
84 paper_id: paper_id.into(),
85 reader: reader.into(),
86 starred: true,
87 to_read: false,
88 read_at: None,
89 },
90 };
91 self.set(&new_state)?;
92 Ok(new_state.starred)
93 }
94
95 pub fn starred_ids(&self, reader: &str) -> Result<std::collections::HashSet<String>, DbError> {
97 let conn = self.db.conn()?;
98 let mut stmt =
99 conn.prepare("SELECT paper_id FROM paper_state WHERE reader = ?1 AND starred = 1")?;
100 let rows = stmt.query_map(params![reader], |row| row.get::<_, String>(0))?;
101 let mut out = std::collections::HashSet::new();
102 for r in rows {
103 out.insert(r?);
104 }
105 Ok(out)
106 }
107
108 pub fn starred_ids_ordered(&self, reader: &str) -> Result<Vec<String>, DbError> {
114 let conn = self.db.conn()?;
115 let mut stmt = conn.prepare(
116 "SELECT paper_id FROM paper_state
117 WHERE reader = ?1 AND starred = 1
118 ORDER BY updated_at DESC",
119 )?;
120 let rows = stmt.query_map(params![reader], |row| row.get::<_, String>(0))?;
121 Ok(rows.filter_map(Result::ok).collect())
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn fresh_db() -> Database {
130 let db = Database::open_in_memory().unwrap();
131 db.migrate().unwrap();
132 let conn = db.conn().unwrap();
134 conn.execute(
135 "INSERT INTO papers (id, title, created_at, updated_at)
136 VALUES ('p1', 't', datetime('now'), datetime('now'))",
137 [],
138 )
139 .unwrap();
140 db
141 }
142
143 #[test]
144 fn toggle_starred_roundtrip() {
145 let db = fresh_db();
146 let repo = SqlitePaperStateRepository::new(db);
147 assert!(repo.toggle_starred("p1", "lars").unwrap());
148 assert!(!repo.toggle_starred("p1", "lars").unwrap());
149 assert!(repo.toggle_starred("p1", "lars").unwrap());
150 assert!(repo.get("p1", "lars").unwrap().is_some_and(|s| s.starred));
151 }
152
153 #[test]
154 fn different_readers_have_independent_state() {
155 let db = fresh_db();
156 let repo = SqlitePaperStateRepository::new(db);
157 repo.toggle_starred("p1", "lars").unwrap();
158 assert!(!repo.get("p1", "claude").unwrap().is_some_and(|s| s.starred));
159 assert!(repo.get("p1", "lars").unwrap().is_some_and(|s| s.starred));
160 }
161
162 #[test]
163 fn starred_ids_lists_only_starred() {
164 let db = fresh_db();
165 let repo = SqlitePaperStateRepository::new(db);
166 repo.toggle_starred("p1", "me").unwrap();
167 let ids = repo.starred_ids("me").unwrap();
168 assert!(ids.contains("p1"));
169 }
170
171 #[test]
172 fn starred_ids_ordered_returns_most_recent_first() {
173 let db = fresh_db();
174 db.conn()
176 .unwrap()
177 .execute(
178 "INSERT INTO papers (id, title, created_at, updated_at)
179 VALUES ('p2', 't2', datetime('now'), datetime('now'))",
180 [],
181 )
182 .unwrap();
183 let repo = SqlitePaperStateRepository::new(db);
184
185 repo.toggle_starred("p1", "lars").unwrap();
188 std::thread::sleep(std::time::Duration::from_millis(10));
189 repo.toggle_starred("p2", "lars").unwrap();
190
191 let ordered = repo.starred_ids_ordered("lars").unwrap();
192 assert_eq!(ordered.len(), 2);
193 assert_eq!(ordered[0], "p2", "most recent first");
194 assert_eq!(ordered[1], "p1");
195 }
196
197 #[test]
198 fn starred_ids_ordered_drops_unstarred() {
199 let db = fresh_db();
200 let repo = SqlitePaperStateRepository::new(db);
201 repo.toggle_starred("p1", "lars").unwrap();
202 repo.toggle_starred("p1", "lars").unwrap(); assert!(repo.starred_ids_ordered("lars").unwrap().is_empty());
204 }
205}