Skip to main content

things_mcp/core/reader/
schema.rs

1//! Run-once schema probe asserting that every column our queries reference
2//! exists on disk. Lets us fail fast with a clear message rather than return
3//! garbage if a future Things upgrade renames or removes a column.
4
5use std::path::Path;
6
7use rusqlite::{Connection, OpenFlags};
8
9use crate::core::error::ThingsError;
10
11/// (table, column) pairs the read path depends on. Add to this list as new
12/// queries land.
13const REQUIRED: &[(&str, &[&str])] = &[
14    (
15        "TMTask",
16        &[
17            "uuid",
18            "title",
19            "type",
20            "status",
21            "trashed",
22            "start",
23            "project",
24            "area",
25            "heading",
26            "notes",
27            "creationDate",
28            "userModificationDate",
29            "startDate",
30            "deadline",
31            "stopDate",
32            "rt1_recurrenceRule",
33            "todayIndex",
34            "index",
35        ],
36    ),
37    ("TMArea", &["uuid", "title", "index"]),
38    ("TMTag", &["uuid", "title", "shortcut", "parent", "index"]),
39    ("TMTaskTag", &["tasks", "tags"]),
40    (
41        "TMChecklistItem",
42        &["uuid", "title", "status", "task", "index"],
43    ),
44];
45
46pub fn probe(db_path: &Path) -> Result<(), ThingsError> {
47    let c = Connection::open_with_flags(
48        db_path,
49        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
50    )?;
51    let mut missing = Vec::new();
52    for (table, cols) in REQUIRED {
53        let table_cols = list_columns(&c, table)?;
54        for col in *cols {
55            if !table_cols.iter().any(|t| t.eq_ignore_ascii_case(col)) {
56                missing.push(format!("{table}.{col}"));
57            }
58        }
59    }
60    if missing.is_empty() {
61        Ok(())
62    } else {
63        Err(ThingsError::SchemaIncompatible {
64            missing,
65            things_version_guess: None,
66        })
67    }
68}
69
70fn list_columns(c: &Connection, table: &str) -> Result<Vec<String>, rusqlite::Error> {
71    let mut stmt = c.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
72    let cols: Result<Vec<String>, _> = stmt.query_map([], |r| r.get::<_, String>(1))?.collect();
73    cols
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::core::reader::fixture::build_fixture;
80    use tempfile::tempdir;
81
82    #[test]
83    fn probe_passes_on_fixture() {
84        let tmp = tempdir().unwrap();
85        let path = tmp.path().join("ok.sqlite");
86        build_fixture(&path).unwrap();
87        probe(&path).expect("schema probe should pass on fixture");
88    }
89
90    #[test]
91    fn probe_reports_missing_columns() {
92        let tmp = tempdir().unwrap();
93        let path = tmp.path().join("bad.sqlite");
94        let c = Connection::open(&path).unwrap();
95        // intentionally drop most of the columns
96        c.execute_batch("CREATE TABLE TMTask (uuid TEXT);").unwrap();
97        let err = probe(&path).unwrap_err();
98        match err {
99            ThingsError::SchemaIncompatible { missing, .. } => {
100                assert!(missing.iter().any(|m| m == "TMTask.title"));
101                assert!(missing.iter().any(|m| m == "TMTask.status"));
102            }
103            other => panic!("unexpected error: {other:?}"),
104        }
105    }
106}