Skip to main content

things_mcp/core/
error.rs

1//! Domain errors. All variants serialise to a stable structured form so MCP
2//! callers see typed errors, never bare strings.
3
4use 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}