1use rusqlite::{OptionalExtension, params};
12
13use crate::error::DbError;
14use crate::sqlite::Database;
15
16#[derive(Clone)]
17pub struct SqliteShortlistRepository {
18 db: Database,
19}
20
21impl SqliteShortlistRepository {
22 pub fn new(db: Database) -> Self {
23 Self { db }
24 }
25
26 pub fn contains(
28 &self,
29 question_id: &str,
30 paper_id: &str,
31 reader: &str,
32 ) -> Result<bool, DbError> {
33 let conn = self.db.conn()?;
34 let exists: Option<i64> = conn
35 .query_row(
36 "SELECT 1 FROM shortlist_members
37 WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
38 params![question_id, paper_id, reader],
39 |r| r.get(0),
40 )
41 .optional()?;
42 Ok(exists.is_some())
43 }
44
45 pub fn toggle(&self, question_id: &str, paper_id: &str, reader: &str) -> Result<bool, DbError> {
51 let conn = self.db.conn()?;
52 let exists: Option<i64> = conn
53 .query_row(
54 "SELECT 1 FROM shortlist_members
55 WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
56 params![question_id, paper_id, reader],
57 |r| r.get(0),
58 )
59 .optional()?;
60 if exists.is_some() {
61 conn.execute(
62 "DELETE FROM shortlist_members
63 WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
64 params![question_id, paper_id, reader],
65 )?;
66 Ok(false)
67 } else {
68 conn.execute(
69 "INSERT INTO shortlist_members (question_id, paper_id, reader, added_at)
70 VALUES (?1, ?2, ?3, ?4)",
71 params![
72 question_id,
73 paper_id,
74 reader,
75 chrono::Utc::now().to_rfc3339()
76 ],
77 )?;
78 Ok(true)
79 }
80 }
81
82 pub fn list(&self, question_id: &str, reader: &str) -> Result<Vec<String>, DbError> {
85 let conn = self.db.conn()?;
86 let mut stmt = conn.prepare(
87 "SELECT paper_id FROM shortlist_members
88 WHERE question_id = ?1 AND reader = ?2
89 ORDER BY added_at ASC",
90 )?;
91 let rows = stmt.query_map(params![question_id, reader], |r| r.get::<_, String>(0))?;
92 Ok(rows.filter_map(Result::ok).collect())
93 }
94
95 pub fn members_set(
98 &self,
99 question_id: &str,
100 reader: &str,
101 ) -> Result<std::collections::HashSet<String>, DbError> {
102 Ok(self.list(question_id, reader)?.into_iter().collect())
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 fn fresh() -> SqliteShortlistRepository {
111 let db = Database::open_in_memory().unwrap();
112 db.migrate().unwrap();
113 let conn = db.conn().unwrap();
114 conn.execute(
116 "INSERT INTO research_questions (id, text, description, created_at, updated_at)
117 VALUES ('q1', 'Q', '', datetime('now'), datetime('now'))",
118 [],
119 )
120 .unwrap();
121 conn.execute(
122 "INSERT INTO papers (id, title, created_at, updated_at)
123 VALUES ('p1', 'T', datetime('now'), datetime('now'))",
124 [],
125 )
126 .unwrap();
127 SqliteShortlistRepository::new(db)
128 }
129
130 #[test]
131 fn toggle_round_trip() {
132 let repo = fresh();
133 assert!(repo.toggle("q1", "p1", "lars").unwrap(), "adds on first");
134 assert!(repo.contains("q1", "p1", "lars").unwrap());
135 assert!(
136 !repo.toggle("q1", "p1", "lars").unwrap(),
137 "removes on second"
138 );
139 assert!(!repo.contains("q1", "p1", "lars").unwrap());
140 }
141
142 #[test]
143 fn list_is_added_at_ascending() {
144 let repo = fresh();
145 {
149 let conn = repo.db.conn().unwrap();
150 conn.execute(
151 "INSERT INTO papers (id, title, created_at, updated_at)
152 VALUES ('p2', 'T2', datetime('now'), datetime('now'))",
153 [],
154 )
155 .unwrap();
156 }
157 repo.toggle("q1", "p2", "lars").unwrap();
158 std::thread::sleep(std::time::Duration::from_millis(10));
159 repo.toggle("q1", "p1", "lars").unwrap();
160 let ids = repo.list("q1", "lars").unwrap();
161 assert_eq!(ids, vec!["p2", "p1"], "oldest first");
162 }
163
164 #[test]
165 fn readers_have_independent_shortlists() {
166 let repo = fresh();
167 repo.toggle("q1", "p1", "lars").unwrap();
168 assert!(!repo.contains("q1", "p1", "claude").unwrap());
169 assert!(repo.contains("q1", "p1", "lars").unwrap());
170 }
171}