Skip to main content

scitadel_db/sqlite/
shortlist.rs

1//! Per-(question, reader) citation shortlist (#133).
2//!
3//! Curated set of papers the reader will cite for a research question.
4//! Drives the Question Dashboard's `c` keybind and feeds
5//! `bib snapshot <question_id>` (#134, 0.6.1).
6//!
7//! Multi-author story: `reader` scope mirrors the annotation and star
8//! conventions — per-reader shortlists diverge until Dolt sync lands
9//! in Phase 5.
10
11use 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    /// Is this paper on `reader`'s shortlist for `question_id`?
27    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    /// Toggle membership. Returns the post-toggle state (`true` = on
46    /// shortlist). Matches the TUI's `c` keybind and MCP
47    /// `toggle_shortlist` semantics. Does the whole check-and-swap
48    /// under one pool connection to avoid deadlocking the single-
49    /// connection in-memory test DB.
50    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    /// Paper IDs on `reader`'s shortlist for `question_id`, in
83    /// added-at-ascending order (the order the reader built the list).
84    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    /// `paper_id` set for use as a contains-check in render loops —
96    /// cheaper than N per-row `contains` calls.
97    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        // Need a question + a paper to satisfy FKs.
115        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        // Insert the second paper in a scoped block so the connection
146        // is released before `repo.toggle` needs one (the test DB's
147        // pool is single-connection).
148        {
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}