Skip to main content

things_mcp/core/writer/
verify.rs

1//! Post-write verification: poll the reader pool until we see the expected
2//! change (verified), the predicate proves the row will never exist
3//! (NotFound — for updates and status-changes only), or the timeout elapses
4//! (Timeout). Bounded `poll_timeout / poll_interval` so a misbehaving Things
5//! cannot hang the MCP call.
6
7use std::time::{Duration, Instant};
8
9use rusqlite::Connection;
10
11use crate::core::error::ThingsError;
12use crate::core::reader::pool::ReaderPool;
13use crate::core::types::{TaskKind, TaskStatus, TodoSummary};
14
15#[derive(Debug, Clone)]
16pub enum VerifyPredicate {
17    /// A row with this title and creationDate ≥ since_unix should exist.
18    /// `kind` selects the row's `type` column: Todo → 0, Project → 1.
19    CreateByTitle {
20        title: String,
21        since_unix: f64,
22        kind: TaskKind,
23    },
24    /// The row at this id should match all populated expected_* fields.
25    UpdateById {
26        id: String,
27        expected_title: Option<String>,
28        expected_notes: Option<String>,
29    },
30    /// The row at this id should have this status.
31    StatusChange { id: String, want: TaskStatus },
32    /// The row at this id should have its project/area column set to the
33    /// expected_list_id. `Some(uuid)` matches when t.project = uuid OR
34    /// t.area = uuid. `None` matches when BOTH columns are NULL (the inbox).
35    MoveById {
36        id: String,
37        expected_list_id: Option<String>,
38    },
39    /// The row at `id` either does or doesn't have the named tag, depending
40    /// on `present`. Used by `things_assign_tag` (`present: true`) and
41    /// `things_unassign_tag` (`present: false`). Tag matched by title via
42    /// the TMTaskTag→TMTag join.
43    TagOnTodoById {
44        id: String,
45        tag: String,
46        present: bool,
47    },
48}
49
50#[derive(Debug)]
51pub enum VerifyOutcome {
52    Verified { row: TodoSummary, latency_ms: u64 },
53    Timeout { latency_ms: u64 },
54    /// Only emitted by UpdateById / StatusChange when the row never exists.
55    /// Plan 4 wires this through `WriteOutcome { verified: false, id: None }`.
56    NotFound { latency_ms: u64 },
57}
58
59pub async fn verify(
60    pool: &ReaderPool,
61    pred: VerifyPredicate,
62    timeout: Duration,
63    interval: Duration,
64) -> Result<VerifyOutcome, ThingsError> {
65    let start = Instant::now();
66
67    // For UpdateById / StatusChange the row should already exist in the DB —
68    // if it never does, no amount of polling will help, so short-circuit.
69    if let VerifyPredicate::UpdateById { id, .. }
70        | VerifyPredicate::StatusChange { id, .. }
71        | VerifyPredicate::MoveById { id, .. }
72        | VerifyPredicate::TagOnTodoById { id, .. } = &pred
73    {
74        let id_for_probe = id.clone();
75        let exists = pool
76            .with_conn(move |c| -> rusqlite::Result<bool> {
77                c.query_row(
78                    "SELECT EXISTS (SELECT 1 FROM TMTask WHERE uuid = ? AND trashed = 0)",
79                    rusqlite::params![id_for_probe],
80                    |r| r.get::<_, i64>(0).map(|n| n != 0),
81                )
82            })
83            .await?;
84        if !exists {
85            return Ok(VerifyOutcome::NotFound {
86                latency_ms: start.elapsed().as_millis() as u64,
87            });
88        }
89    }
90
91    loop {
92        let pred_clone = pred.clone();
93        let found = pool
94            .with_conn(move |c| check_once(c, &pred_clone))
95            .await?;
96        if let Some(row) = found {
97            return Ok(VerifyOutcome::Verified {
98                row,
99                latency_ms: start.elapsed().as_millis() as u64,
100            });
101        }
102        if start.elapsed() >= timeout {
103            return Ok(VerifyOutcome::Timeout {
104                latency_ms: start.elapsed().as_millis() as u64,
105            });
106        }
107        tokio::time::sleep(interval).await;
108    }
109}
110
111fn check_once(c: &Connection, pred: &VerifyPredicate) -> rusqlite::Result<Option<TodoSummary>> {
112    use crate::core::reader::queries::{row_to_summary, SUMMARY_COLS, SUMMARY_COLS_LEN};
113    match pred {
114        VerifyPredicate::CreateByTitle { title, since_unix, kind } => {
115            let type_int: i64 = match kind {
116                TaskKind::Todo => 0,
117                TaskKind::Project => 1,
118                TaskKind::Heading => 2,
119            };
120            let sql = format!(
121                r#"
122                SELECT {SUMMARY_COLS}
123                FROM TMTask AS t
124                WHERE t.trashed = 0
125                  AND t.type = ?
126                  AND t.title = ?
127                  AND t.creationDate >= ?
128                ORDER BY t.creationDate DESC
129                LIMIT 1
130                "#
131            );
132            let mut stmt = c.prepare_cached(&sql)?;
133            let mut rows = stmt.query(rusqlite::params![type_int, title, since_unix])?;
134            if let Some(r) = rows.next()? {
135                return row_to_summary(r).map(Some);
136            }
137            Ok(None)
138        }
139        VerifyPredicate::UpdateById {
140            id,
141            expected_title,
142            expected_notes,
143        } => {
144            let sql = format!(
145                r#"
146                SELECT {SUMMARY_COLS}, t.notes
147                FROM TMTask AS t
148                WHERE t.uuid = ? AND t.trashed = 0
149                LIMIT 1
150                "#
151            );
152            let mut stmt = c.prepare_cached(&sql)?;
153            let mut rows = stmt.query(rusqlite::params![id])?;
154            let Some(r) = rows.next()? else {
155                return Ok(None);
156            };
157            let summary = row_to_summary(r)?;
158            let notes: Option<String> = r.get(SUMMARY_COLS_LEN)?;
159            if let Some(want) = expected_title.as_ref() {
160                if summary.title != *want {
161                    return Ok(None);
162                }
163            }
164            if let Some(want) = expected_notes.as_ref() {
165                if notes.as_deref() != Some(want.as_str()) {
166                    return Ok(None);
167                }
168            }
169            Ok(Some(summary))
170        }
171        VerifyPredicate::StatusChange { id, want } => {
172            let sql = format!(
173                r#"
174                SELECT {SUMMARY_COLS}
175                FROM TMTask AS t
176                WHERE t.uuid = ? AND t.trashed = 0
177                LIMIT 1
178                "#
179            );
180            let mut stmt = c.prepare_cached(&sql)?;
181            let mut rows = stmt.query(rusqlite::params![id])?;
182            let Some(r) = rows.next()? else {
183                return Ok(None);
184            };
185            let summary = row_to_summary(r)?;
186            if summary.status == *want {
187                Ok(Some(summary))
188            } else {
189                Ok(None)
190            }
191        }
192        VerifyPredicate::MoveById { id, expected_list_id } => {
193            let sql = format!(
194                r#"
195                SELECT {SUMMARY_COLS}
196                FROM TMTask AS t
197                WHERE t.uuid = ? AND t.trashed = 0
198                LIMIT 1
199                "#
200            );
201            let mut stmt = c.prepare_cached(&sql)?;
202            let mut rows = stmt.query(rusqlite::params![id])?;
203            let Some(r) = rows.next()? else {
204                return Ok(None);
205            };
206            let summary = row_to_summary(r)?;
207            let matches = match expected_list_id.as_deref() {
208                None => summary.project_id.is_none() && summary.area_id.is_none(),
209                Some(want) => {
210                    summary.project_id.as_deref() == Some(want)
211                        || summary.area_id.as_deref() == Some(want)
212                }
213            };
214            if matches {
215                Ok(Some(summary))
216            } else {
217                Ok(None)
218            }
219        }
220        VerifyPredicate::TagOnTodoById { id, tag, present } => {
221            // Tag-presence join: TMTaskTag.tasks = task uuid, TMTaskTag.tags
222            // = tag uuid, TMTag.title = tag's user-facing name.
223            let has_tag_sql = r#"
224                SELECT EXISTS (
225                    SELECT 1
226                    FROM TMTaskTag AS tt
227                    JOIN TMTag      AS g  ON g.uuid = tt.tags
228                    WHERE tt.tasks = ? AND g.title = ?
229                )
230            "#;
231            let mut stmt = c.prepare_cached(has_tag_sql)?;
232            let has_tag: bool = stmt
233                .query_row(rusqlite::params![id, tag], |r| {
234                    r.get::<_, i64>(0).map(|n| n != 0)
235                })?;
236            if has_tag != *present {
237                return Ok(None);
238            }
239            // Emit a summary just like the other arms do.
240            let summary_sql = format!(
241                r#"
242                SELECT {SUMMARY_COLS}
243                FROM TMTask AS t
244                WHERE t.uuid = ? AND t.trashed = 0
245                LIMIT 1
246                "#
247            );
248            let mut summary_stmt = c.prepare_cached(&summary_sql)?;
249            let mut rows = summary_stmt.query(rusqlite::params![id])?;
250            let Some(r) = rows.next()? else { return Ok(None) };
251            row_to_summary(r).map(Some)
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::core::reader::fixture::build_fixture;
260    use tempfile::tempdir;
261
262    fn cfg() -> (Duration, Duration) {
263        (Duration::from_millis(500), Duration::from_millis(20))
264    }
265
266    async fn open_pool() -> (tempfile::TempDir, ReaderPool) {
267        let tmp = tempdir().unwrap();
268        let path = tmp.path().join("p.sqlite");
269        build_fixture(&path).unwrap();
270        let pool = ReaderPool::new(path, 2).await.unwrap();
271        (tmp, pool)
272    }
273
274    #[tokio::test]
275    async fn verify_create_by_title_finds_existing_row() {
276        let (_tmp, pool) = open_pool().await;
277        let (timeout, interval) = cfg();
278        // The fixture seeds 'Buy milk' with creationDate=1715000000.0.
279        let out = verify(
280            &pool,
281            VerifyPredicate::CreateByTitle {
282                title: "Buy milk".into(),
283                since_unix: 0.0,
284                kind: TaskKind::Todo,
285            },
286            timeout,
287            interval,
288        )
289        .await
290        .unwrap();
291        match out {
292            VerifyOutcome::Verified { row, .. } => assert_eq!(row.title, "Buy milk"),
293            other => panic!("expected Verified, got {:?}", other),
294        }
295    }
296
297    #[tokio::test]
298    async fn verify_create_by_title_times_out_when_title_absent() {
299        let (_tmp, pool) = open_pool().await;
300        let (timeout, interval) = cfg();
301        let out = verify(
302            &pool,
303            VerifyPredicate::CreateByTitle {
304                title: "Nothing in the fixture matches this".into(),
305                since_unix: 0.0,
306                kind: TaskKind::Todo,
307            },
308            timeout,
309            interval,
310        )
311        .await
312        .unwrap();
313        assert!(matches!(out, VerifyOutcome::Timeout { .. }));
314    }
315
316    #[tokio::test]
317    async fn verify_update_by_id_matches_when_fields_align() {
318        let (_tmp, pool) = open_pool().await;
319        let (timeout, interval) = cfg();
320        let out = verify(
321            &pool,
322            VerifyPredicate::UpdateById {
323                id: "todo-1".into(),
324                expected_title: Some("Buy milk".into()),
325                expected_notes: None,
326            },
327            timeout,
328            interval,
329        )
330        .await
331        .unwrap();
332        match out {
333            VerifyOutcome::Verified { row, .. } => assert_eq!(row.id, "todo-1"),
334            other => panic!("expected Verified, got {:?}", other),
335        }
336    }
337
338    #[tokio::test]
339    async fn verify_update_by_id_not_found_short_circuits() {
340        let (_tmp, pool) = open_pool().await;
341        let (timeout, interval) = cfg();
342        let start = std::time::Instant::now();
343        let out = verify(
344            &pool,
345            VerifyPredicate::UpdateById {
346                id: "does-not-exist".into(),
347                expected_title: None,
348                expected_notes: None,
349            },
350            timeout,
351            interval,
352        )
353        .await
354        .unwrap();
355        assert!(matches!(out, VerifyOutcome::NotFound { .. }));
356        // Must short-circuit well under the timeout.
357        assert!(
358            start.elapsed() < Duration::from_millis(200),
359            "expected NotFound to short-circuit, took {:?}",
360            start.elapsed()
361        );
362    }
363
364    #[tokio::test]
365    async fn verify_status_change_matches_when_status_equals_want() {
366        let (_tmp, pool) = open_pool().await;
367        let (timeout, interval) = cfg();
368        // The fixture's 'todo-3 Pay tax bill' has SQLite status=3 → TaskStatus::Completed.
369        let out = verify(
370            &pool,
371            VerifyPredicate::StatusChange {
372                id: "todo-3".into(),
373                want: TaskStatus::Completed,
374            },
375            timeout,
376            interval,
377        )
378        .await
379        .unwrap();
380        match out {
381            VerifyOutcome::Verified { row, .. } => assert_eq!(row.id, "todo-3"),
382            other => panic!("expected Verified, got {:?}", other),
383        }
384    }
385
386    #[tokio::test]
387    async fn verify_move_by_id_matches_when_row_under_expected_list() {
388        let (_tmp, pool) = open_pool().await;
389        let (timeout, interval) = cfg();
390        // The fixture's todo-4 lives under project proj-1.
391        let out = verify(
392            &pool,
393            VerifyPredicate::MoveById {
394                id: "todo-4".into(),
395                expected_list_id: Some("proj-1".into()),
396            },
397            timeout,
398            interval,
399        )
400        .await
401        .unwrap();
402        match out {
403            VerifyOutcome::Verified { row, .. } => {
404                assert_eq!(row.id, "todo-4");
405                assert_eq!(row.project_id.as_deref(), Some("proj-1"));
406            }
407            other => panic!("expected Verified, got {:?}", other),
408        }
409    }
410
411    #[tokio::test]
412    async fn verify_move_by_id_inbox_matches_when_both_parent_columns_null() {
413        let (_tmp, pool) = open_pool().await;
414        let (timeout, interval) = cfg();
415        // The fixture's todo-1 ('Buy milk') has no project + no area.
416        let out = verify(
417            &pool,
418            VerifyPredicate::MoveById {
419                id: "todo-1".into(),
420                expected_list_id: None,
421            },
422            timeout,
423            interval,
424        )
425        .await
426        .unwrap();
427        assert!(matches!(out, VerifyOutcome::Verified { .. }));
428    }
429
430    #[tokio::test]
431    async fn verify_tag_on_todo_by_id_matches_when_present_true_and_tag_set() {
432        let (_tmp, pool) = open_pool().await;
433        let (timeout, interval) = cfg();
434        // Fixture: todo-2 carries the 'Errand' tag.
435        let out = verify(
436            &pool,
437            VerifyPredicate::TagOnTodoById {
438                id: "todo-2".into(),
439                tag: "Errand".into(),
440                present: true,
441            },
442            timeout,
443            interval,
444        )
445        .await
446        .unwrap();
447        match out {
448            VerifyOutcome::Verified { row, .. } => assert_eq!(row.id, "todo-2"),
449            other => panic!("expected Verified, got {:?}", other),
450        }
451    }
452
453    #[tokio::test]
454    async fn verify_tag_on_todo_by_id_matches_when_present_false_and_tag_absent() {
455        let (_tmp, pool) = open_pool().await;
456        let (timeout, interval) = cfg();
457        // Fixture: todo-1 ('Buy milk') has no tags.
458        let out = verify(
459            &pool,
460            VerifyPredicate::TagOnTodoById {
461                id: "todo-1".into(),
462                tag: "Errand".into(),
463                present: false,
464            },
465            timeout,
466            interval,
467        )
468        .await
469        .unwrap();
470        assert!(matches!(out, VerifyOutcome::Verified { .. }));
471    }
472}