use rusqlite::{OptionalExtension, params};
use crate::error::DbError;
use crate::sqlite::Database;
#[derive(Clone)]
pub struct SqliteShortlistRepository {
db: Database,
}
impl SqliteShortlistRepository {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn contains(
&self,
question_id: &str,
paper_id: &str,
reader: &str,
) -> Result<bool, DbError> {
let conn = self.db.conn()?;
let exists: Option<i64> = conn
.query_row(
"SELECT 1 FROM shortlist_members
WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
params![question_id, paper_id, reader],
|r| r.get(0),
)
.optional()?;
Ok(exists.is_some())
}
pub fn toggle(&self, question_id: &str, paper_id: &str, reader: &str) -> Result<bool, DbError> {
let conn = self.db.conn()?;
let exists: Option<i64> = conn
.query_row(
"SELECT 1 FROM shortlist_members
WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
params![question_id, paper_id, reader],
|r| r.get(0),
)
.optional()?;
if exists.is_some() {
conn.execute(
"DELETE FROM shortlist_members
WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
params![question_id, paper_id, reader],
)?;
Ok(false)
} else {
conn.execute(
"INSERT INTO shortlist_members (question_id, paper_id, reader, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![
question_id,
paper_id,
reader,
chrono::Utc::now().to_rfc3339()
],
)?;
Ok(true)
}
}
pub fn list(&self, question_id: &str, reader: &str) -> Result<Vec<String>, DbError> {
let conn = self.db.conn()?;
let mut stmt = conn.prepare(
"SELECT paper_id FROM shortlist_members
WHERE question_id = ?1 AND reader = ?2
ORDER BY added_at ASC",
)?;
let rows = stmt.query_map(params![question_id, reader], |r| r.get::<_, String>(0))?;
Ok(rows.filter_map(Result::ok).collect())
}
pub fn members_set(
&self,
question_id: &str,
reader: &str,
) -> Result<std::collections::HashSet<String>, DbError> {
Ok(self.list(question_id, reader)?.into_iter().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh() -> SqliteShortlistRepository {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let conn = db.conn().unwrap();
conn.execute(
"INSERT INTO research_questions (id, text, description, created_at, updated_at)
VALUES ('q1', 'Q', '', datetime('now'), datetime('now'))",
[],
)
.unwrap();
conn.execute(
"INSERT INTO papers (id, title, created_at, updated_at)
VALUES ('p1', 'T', datetime('now'), datetime('now'))",
[],
)
.unwrap();
SqliteShortlistRepository::new(db)
}
#[test]
fn toggle_round_trip() {
let repo = fresh();
assert!(repo.toggle("q1", "p1", "lars").unwrap(), "adds on first");
assert!(repo.contains("q1", "p1", "lars").unwrap());
assert!(
!repo.toggle("q1", "p1", "lars").unwrap(),
"removes on second"
);
assert!(!repo.contains("q1", "p1", "lars").unwrap());
}
#[test]
fn list_is_added_at_ascending() {
let repo = fresh();
{
let conn = repo.db.conn().unwrap();
conn.execute(
"INSERT INTO papers (id, title, created_at, updated_at)
VALUES ('p2', 'T2', datetime('now'), datetime('now'))",
[],
)
.unwrap();
}
repo.toggle("q1", "p2", "lars").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
repo.toggle("q1", "p1", "lars").unwrap();
let ids = repo.list("q1", "lars").unwrap();
assert_eq!(ids, vec!["p2", "p1"], "oldest first");
}
#[test]
fn readers_have_independent_shortlists() {
let repo = fresh();
repo.toggle("q1", "p1", "lars").unwrap();
assert!(!repo.contains("q1", "p1", "claude").unwrap());
assert!(repo.contains("q1", "p1", "lars").unwrap());
}
}