Skip to main content

things_mcp/core/reader/
fts.rs

1//! FTS5 capability detection.
2//!
3//! At startup we inspect `sqlite_master` for any virtual table created
4//! `USING fts5`, surface the resolved table name + columns on `AppState`,
5//! and log the result. The search query path does not yet consume this —
6//! activation waits until we can verify Things' actual FTS5 schema against
7//! a live install. Until then, this module is purely informational.
8
9use rusqlite::Connection;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FtsCapability {
13    /// Discovered virtual table name (e.g. `TMTask_searchstr_data`).
14    pub table: String,
15    /// Columns the FTS5 index exposes (from `PRAGMA table_info`).
16    pub columns: Vec<String>,
17}
18
19/// Inspect `sqlite_master` for an FTS5 virtual table that looks Things-related.
20/// Returns the first match (table + its columns) or `None` if no FTS5 indices
21/// are present.
22///
23/// Heuristic: any `CREATE VIRTUAL TABLE ... USING fts5(...)` whose name starts
24/// with `TMTask` or `TMSearchInfo`. This may be over- or under-inclusive on
25/// future Things versions; activation in the search query is gated by an
26/// explicit verification step in a later plan.
27pub fn detect(conn: &Connection) -> rusqlite::Result<Option<FtsCapability>> {
28    let mut stmt = conn.prepare(
29        "SELECT name FROM sqlite_master \
30         WHERE type = 'table' \
31           AND sql IS NOT NULL \
32           AND sql LIKE '%USING fts5%' \
33           AND (name LIKE 'TMTask%' OR name LIKE 'TMSearchInfo%') \
34         ORDER BY name \
35         LIMIT 1",
36    )?;
37    let mut rows = stmt.query([])?;
38    let Some(row) = rows.next()? else {
39        return Ok(None);
40    };
41    let table: String = row.get(0)?;
42    let columns = list_columns(conn, &table)?;
43    Ok(Some(FtsCapability { table, columns }))
44}
45
46fn list_columns(conn: &Connection, table: &str) -> rusqlite::Result<Vec<String>> {
47    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
48    let cols: Result<Vec<String>, _> = stmt
49        .query_map([], |r| r.get::<_, String>(1))?
50        .collect();
51    cols
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use rusqlite::Connection;
58
59    #[test]
60    fn detect_returns_none_on_empty_db() {
61        let c = Connection::open_in_memory().unwrap();
62        c.execute_batch("CREATE TABLE TMTask (uuid TEXT);").unwrap();
63        assert_eq!(detect(&c).unwrap(), None);
64    }
65
66    #[test]
67    fn detect_finds_fts5_virtual_table() {
68        let c = Connection::open_in_memory().unwrap();
69        c.execute_batch(
70            "CREATE TABLE TMTask (uuid TEXT);
71             CREATE VIRTUAL TABLE TMTask_searchstr USING fts5(title, notes, content='');",
72        )
73        .unwrap();
74        let cap = detect(&c).unwrap().expect("FTS5 table should be detected");
75        assert_eq!(cap.table, "TMTask_searchstr");
76        assert!(cap.columns.iter().any(|c| c == "title"));
77        assert!(cap.columns.iter().any(|c| c == "notes"));
78    }
79
80    #[test]
81    fn detect_ignores_non_fts_virtual_tables() {
82        let c = Connection::open_in_memory().unwrap();
83        // r-tree is another virtual table type; should not be misidentified.
84        c.execute_batch(
85            "CREATE TABLE TMTask (uuid TEXT);
86             CREATE VIRTUAL TABLE TMTask_rtree USING rtree(id, minX, maxX);",
87        )
88        .unwrap();
89        assert_eq!(detect(&c).unwrap(), None);
90    }
91
92    #[test]
93    fn detect_ignores_unrelated_fts_tables() {
94        let c = Connection::open_in_memory().unwrap();
95        // An FTS5 table that doesn't look TMTask-related must not match.
96        c.execute_batch(
97            "CREATE TABLE TMTask (uuid TEXT);
98             CREATE VIRTUAL TABLE Whatever_fts USING fts5(stuff);",
99        )
100        .unwrap();
101        assert_eq!(detect(&c).unwrap(), None);
102    }
103}