things_mcp/core/reader/
schema.rs1use std::path::Path;
6
7use rusqlite::{Connection, OpenFlags};
8
9use crate::core::error::ThingsError;
10
11const 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 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}