1use serde::Serialize;
5use thiserror::Error;
6
7#[derive(Debug, Error, Serialize)]
8#[serde(tag = "kind", rename_all = "snake_case")]
9pub enum ThingsError {
10 #[error(
11 "missing Things auth-token (set THINGS_AUTH_TOKEN or config.toml [things].auth_token)"
12 )]
13 MissingAuthToken { hint: String },
14
15 #[error("Things SQLite schema is incompatible — missing columns: {missing:?}")]
16 SchemaIncompatible {
17 missing: Vec<String>,
18 things_version_guess: Option<String>,
19 },
20
21 #[error("Things database is locked; retry in {retry_in_ms} ms")]
22 DbLocked { retry_in_ms: u32 },
23
24 #[error("write was unverified after {elapsed_ms} ms; payload echo follows")]
25 WriteUnverified {
26 payload_echo: String,
27 elapsed_ms: u32,
28 },
29
30 #[error("unsupported recurrence pattern '{pattern}'; supported: {supported:?}")]
31 UnsupportedRecurrence {
32 pattern: String,
33 supported: Vec<String>,
34 },
35
36 #[error("operation not allowed on repeating item {id} (field '{field}')")]
37 OperationNotAllowedOnRepeatingItem { id: String, field: String },
38
39 #[error("dry-run only (test-DB mode): would have opened {url}")]
40 DryRun {
41 url: String,
42 payload: serde_json::Value,
43 },
44
45 #[error("write executor failed: {message}")]
46 ExecutorFailed {
47 #[serde(rename = "source")]
48 message: String,
49 },
50
51 #[error("Things rejected the auth-token (writes will not succeed)")]
52 AuthTokenRejected,
53
54 #[error("AppleScript exited {exit}: {stderr}")]
55 AppleScriptFailed { stderr: String, exit: i32 },
56
57 #[error("Things app is not running")]
58 ThingsAppNotRunning,
59
60 #[error("invalid input for '{field}': {reason}")]
61 InvalidInput { field: String, reason: String },
62
63 #[error("io: {0}")]
64 Io(String),
65
66 #[error("sqlite: {0}")]
67 Sqlite(String),
68
69 #[error("writes refused in test-DB mode (set THINGS_MCP_ALLOW_WRITES_ON_TEST_DB=1 to allow dry-run writes)")]
70 TestDbWriteForbidden,
71}
72
73impl From<std::io::Error> for ThingsError {
74 fn from(e: std::io::Error) -> Self {
75 Self::Io(e.to_string())
76 }
77}
78
79impl From<rusqlite::Error> for ThingsError {
80 fn from(e: rusqlite::Error) -> Self {
81 Self::Sqlite(e.to_string())
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn missing_auth_token_serialises_to_tagged_json() {
91 let err = ThingsError::MissingAuthToken {
92 hint: "set THINGS_AUTH_TOKEN".into(),
93 };
94 let v = serde_json::to_value(&err).unwrap();
95 assert_eq!(v["kind"], "missing_auth_token");
96 assert_eq!(v["hint"], "set THINGS_AUTH_TOKEN");
97 }
98
99 #[test]
100 fn schema_incompatible_carries_missing_columns() {
101 let err = ThingsError::SchemaIncompatible {
102 missing: vec!["TMTask.uuid".into()],
103 things_version_guess: None,
104 };
105 let v = serde_json::to_value(&err).unwrap();
106 assert_eq!(v["kind"], "schema_incompatible");
107 assert_eq!(v["missing"][0], "TMTask.uuid");
108 }
109
110 #[test]
111 fn test_db_write_forbidden_serialises_to_tagged_json() {
112 let err = ThingsError::TestDbWriteForbidden;
113 let v = serde_json::to_value(&err).unwrap();
114 assert_eq!(v["kind"], "test_db_write_forbidden");
115 }
116
117 #[test]
118 fn executor_failed_carries_source() {
119 let err = ThingsError::ExecutorFailed {
120 message: "spawn: ENOENT".into(),
121 };
122 let v = serde_json::to_value(&err).unwrap();
123 assert_eq!(v["kind"], "executor_failed");
124 assert_eq!(v["source"], "spawn: ENOENT");
125 }
126}