Skip to main content

scitadel_db/sqlite/
tui_state.rs

1//! Singleton TUI-selection state (#122). Lets an MCP-side agent read
2//! "what is the open scitadel TUI looking at right now?" so it can
3//! score the current paper / draft a question from the current
4//! context without the user pasting IDs.
5//!
6//! Last-writer-wins if multiple TUIs run concurrently — acceptable for
7//! v1 since the TUI is a single-pane terminal app.
8
9use rusqlite::params;
10
11use crate::error::DbError;
12use crate::sqlite::Database;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TuiState {
16    /// Active tab name: "Searches" | "Papers" | "Questions"
17    pub tab: String,
18    pub paper_id: Option<String>,
19    pub search_id: Option<String>,
20    pub question_id: Option<String>,
21    pub annotation_id: Option<String>,
22    /// RFC3339 timestamp of the last TUI-side write.
23    pub updated_at: String,
24}
25
26#[derive(Clone)]
27pub struct SqliteTuiStateRepository {
28    db: Database,
29}
30
31impl SqliteTuiStateRepository {
32    pub fn new(db: Database) -> Self {
33        Self { db }
34    }
35
36    /// Upsert the singleton row. Called by the TUI on every focus /
37    /// overlay / tab change. Idempotent.
38    pub fn set(&self, state: &TuiState) -> Result<(), DbError> {
39        let conn = self.db.conn()?;
40        conn.execute(
41            "INSERT INTO tui_state (id, tab, paper_id, search_id, question_id, annotation_id, updated_at)
42             VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
43             ON CONFLICT(id) DO UPDATE SET
44                 tab           = excluded.tab,
45                 paper_id      = excluded.paper_id,
46                 search_id     = excluded.search_id,
47                 question_id   = excluded.question_id,
48                 annotation_id = excluded.annotation_id,
49                 updated_at    = excluded.updated_at",
50            params![
51                state.tab,
52                state.paper_id,
53                state.search_id,
54                state.question_id,
55                state.annotation_id,
56                state.updated_at,
57            ],
58        )?;
59        Ok(())
60    }
61
62    /// Read the singleton row. Returns `None` if no TUI has ever
63    /// written (rather than synthesising an empty default).
64    pub fn get(&self) -> Result<Option<TuiState>, DbError> {
65        let conn = self.db.conn()?;
66        let mut stmt = conn.prepare(
67            "SELECT tab, paper_id, search_id, question_id, annotation_id, updated_at
68             FROM tui_state WHERE id = 1",
69        )?;
70        let mut rows = stmt.query([])?;
71        if let Some(row) = rows.next()? {
72            Ok(Some(TuiState {
73                tab: row.get(0)?,
74                paper_id: row.get(1)?,
75                search_id: row.get(2)?,
76                question_id: row.get(3)?,
77                annotation_id: row.get(4)?,
78                updated_at: row.get(5)?,
79            }))
80        } else {
81            Ok(None)
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn fresh() -> SqliteTuiStateRepository {
91        let db = Database::open_in_memory().unwrap();
92        db.migrate().unwrap();
93        SqliteTuiStateRepository::new(db)
94    }
95
96    #[test]
97    fn empty_until_first_write() {
98        let repo = fresh();
99        assert!(repo.get().unwrap().is_none());
100    }
101
102    #[test]
103    fn upsert_round_trip() {
104        let repo = fresh();
105        let s = TuiState {
106            tab: "Papers".into(),
107            paper_id: Some("p-abc".into()),
108            search_id: None,
109            question_id: None,
110            annotation_id: None,
111            updated_at: "2026-04-20T00:00:00Z".into(),
112        };
113        repo.set(&s).unwrap();
114        assert_eq!(repo.get().unwrap().unwrap(), s);
115
116        // Second write replaces, doesn't append.
117        let s2 = TuiState {
118            tab: "Questions".into(),
119            paper_id: None,
120            search_id: None,
121            question_id: Some("q-xyz".into()),
122            annotation_id: None,
123            updated_at: "2026-04-20T00:01:00Z".into(),
124        };
125        repo.set(&s2).unwrap();
126        assert_eq!(repo.get().unwrap().unwrap(), s2);
127    }
128}