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    /// Latest update timestamp across the tables that the `bib watch`
106    /// engine considers shared-document state for `question_id`:
107    /// `papers` rows referenced by the shortlist, `paper_state` rows
108    /// for those papers, and `shortlist_members` membership changes.
109    /// Returns `None` when the question has no shortlist or all
110    /// touched tables are empty. Caller compares string equality
111    /// (RFC3339 timestamps are lex-comparable per ISO 8601).
112    ///
113    /// Note: stars are personal-view state and are filtered at the
114    /// watch-engine layer, NOT excluded here — the engine ignores
115    /// changes whose only signal is `paper_state.starred` flipping.
116    /// This query also doesn't join `annotations` directly: the
117    /// import-side bib-content build folds annotation `note=` text
118    /// through the paper row, so a meaningful annotation change
119    /// surfaces via `papers.updated_at`.
120    pub fn max_updated_at_for_question(
121        &self,
122        question_id: &str,
123    ) -> Result<Option<String>, DbError> {
124        let conn = self.db.conn()?;
125        let ts: Option<String> = conn
126            .query_row(
127                "SELECT MAX(t) FROM (
128                    SELECT MAX(updated_at) AS t FROM papers
129                        WHERE id IN (SELECT paper_id FROM shortlist_members
130                                     WHERE question_id = ?1)
131                    UNION ALL
132                    SELECT MAX(updated_at) AS t FROM paper_state
133                        WHERE paper_id IN (SELECT paper_id FROM shortlist_members
134                                           WHERE question_id = ?1)
135                    UNION ALL
136                    SELECT MAX(added_at) AS t FROM shortlist_members
137                        WHERE question_id = ?1
138                 )",
139                params![question_id],
140                |r| r.get::<_, Option<String>>(0),
141            )
142            .optional()?
143            .flatten();
144        Ok(ts)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn fresh() -> SqliteShortlistRepository {
153        let db = Database::open_in_memory().unwrap();
154        db.migrate().unwrap();
155        let conn = db.conn().unwrap();
156        // Need a question + a paper to satisfy FKs.
157        conn.execute(
158            "INSERT INTO research_questions (id, text, description, created_at, updated_at)
159             VALUES ('q1', 'Q', '', datetime('now'), datetime('now'))",
160            [],
161        )
162        .unwrap();
163        conn.execute(
164            "INSERT INTO papers (id, title, created_at, updated_at)
165             VALUES ('p1', 'T', datetime('now'), datetime('now'))",
166            [],
167        )
168        .unwrap();
169        SqliteShortlistRepository::new(db)
170    }
171
172    #[test]
173    fn toggle_round_trip() {
174        let repo = fresh();
175        assert!(repo.toggle("q1", "p1", "lars").unwrap(), "adds on first");
176        assert!(repo.contains("q1", "p1", "lars").unwrap());
177        assert!(
178            !repo.toggle("q1", "p1", "lars").unwrap(),
179            "removes on second"
180        );
181        assert!(!repo.contains("q1", "p1", "lars").unwrap());
182    }
183
184    #[test]
185    fn list_is_added_at_ascending() {
186        let repo = fresh();
187        // Insert the second paper in a scoped block so the connection
188        // is released before `repo.toggle` needs one (the test DB's
189        // pool is single-connection).
190        {
191            let conn = repo.db.conn().unwrap();
192            conn.execute(
193                "INSERT INTO papers (id, title, created_at, updated_at)
194                 VALUES ('p2', 'T2', datetime('now'), datetime('now'))",
195                [],
196            )
197            .unwrap();
198        }
199        repo.toggle("q1", "p2", "lars").unwrap();
200        std::thread::sleep(std::time::Duration::from_millis(10));
201        repo.toggle("q1", "p1", "lars").unwrap();
202        let ids = repo.list("q1", "lars").unwrap();
203        assert_eq!(ids, vec!["p2", "p1"], "oldest first");
204    }
205
206    #[test]
207    fn readers_have_independent_shortlists() {
208        let repo = fresh();
209        repo.toggle("q1", "p1", "lars").unwrap();
210        assert!(!repo.contains("q1", "p1", "claude").unwrap());
211        assert!(repo.contains("q1", "p1", "lars").unwrap());
212    }
213}