1use serde::Serialize;
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5pub enum AppError {
6 #[error("{0}")]
7 InvalidInput(String),
8 #[error("{0}")]
9 Auth(String),
10 #[error("{0}")]
11 ReadOnly(String),
12 #[error("{0}")]
13 Permission(String),
14 #[error("{0}")]
15 NotFound(String),
16 #[error("Microsoft Graph throttled the request")]
17 RateLimit(Option<u64>),
18 #[error("{0}")]
19 Api(String),
20 #[error("{0}")]
21 NonInteractive(String),
22 #[error("{0}")]
23 ConfirmationRequired(String),
24 #[error("{0}")]
25 Unconfirmed(String),
26 #[error("{message} (client message ID: {client_message_id})")]
27 UnconfirmedSend {
28 message: String,
29 chat_id: String,
30 client_message_id: String,
31 },
32 #[error("{0}")]
33 Unsupported(String),
34 #[error("{0}")]
35 Desktop(String),
36 #[error("{0}")]
37 Unhealthy(String),
38 #[error("{0}")]
39 Io(#[from] std::io::Error),
40 #[error("{0}")]
41 Unexpected(String),
42}
43
44#[derive(Debug, Clone, Copy, Serialize)]
45pub struct ErrorContract {
46 pub kind: &'static str,
47 pub exit_code: i32,
48 pub retryable: bool,
49 pub description: &'static str,
50}
51
52pub const INVALID_INPUT: ErrorContract = ErrorContract {
53 kind: "invalid_input",
54 exit_code: 2,
55 retryable: false,
56 description: "Arguments or local configuration are invalid",
57};
58pub const AUTH: ErrorContract = ErrorContract {
59 kind: "auth",
60 exit_code: 3,
61 retryable: false,
62 description: "Authentication is missing, expired, or rejected",
63};
64pub const PERMISSION: ErrorContract = ErrorContract {
65 kind: "permission_denied",
66 exit_code: 5,
67 retryable: false,
68 description: "The tenant or granted scopes do not permit the operation",
69};
70pub const NOT_FOUND: ErrorContract = ErrorContract {
71 kind: "not_found",
72 exit_code: 4,
73 retryable: false,
74 description: "The requested Teams resource does not exist",
75};
76pub const RATE_LIMIT: ErrorContract = ErrorContract {
77 kind: "rate_limit",
78 exit_code: 6,
79 retryable: true,
80 description: "Microsoft Graph throttled the request",
81};
82pub const API: ErrorContract = ErrorContract {
83 kind: "api_error",
84 exit_code: 5,
85 retryable: false,
86 description: "Microsoft Graph returned an API error",
87};
88pub const NON_INTERACTIVE: ErrorContract = ErrorContract {
89 kind: "tty_required",
90 exit_code: 2,
91 retryable: false,
92 description: "An interactive command was invoked without a terminal",
93};
94pub const CONFIRMATION_REQUIRED: ErrorContract = ErrorContract {
95 kind: "confirmation_required",
96 exit_code: 2,
97 retryable: false,
98 description: "The operation needs confirmation: answer the prompt in a terminal, or pass --yes",
99};
100pub const UNCONFIRMED: ErrorContract = ErrorContract {
101 kind: "unconfirmed",
102 exit_code: 8,
103 retryable: false,
104 description: "A send may have been delivered but was not confirmed; check the conversation before sending again",
105};
106pub const UNEXPECTED: ErrorContract = ErrorContract {
107 kind: "unexpected_error",
108 exit_code: 1,
109 retryable: false,
110 description: "An unexpected local or transport error occurred",
111};
112pub const READ_ONLY: ErrorContract = ErrorContract {
113 kind: "read_only",
114 exit_code: 2,
115 retryable: false,
116 description: "The active profile blocks remote write operations",
117};
118
119pub const UNSUPPORTED: ErrorContract = ErrorContract {
120 kind: "unsupported",
121 exit_code: 2,
122 retryable: false,
123 description: "The selected backend or platform does not support this operation",
124};
125pub const DESKTOP: ErrorContract = ErrorContract {
126 kind: "desktop_error",
127 exit_code: 5,
128 retryable: false,
129 description: "The desktop backend could not complete the request",
130};
131
132pub const UNHEALTHY: ErrorContract = ErrorContract {
133 kind: "unhealthy",
134 exit_code: 7,
135 retryable: false,
136 description: "Diagnostics completed and at least one check failed",
137};
138
139pub const ALL: &[ErrorContract] = &[
140 INVALID_INPUT,
141 AUTH,
142 READ_ONLY,
143 PERMISSION,
144 NOT_FOUND,
145 RATE_LIMIT,
146 API,
147 NON_INTERACTIVE,
148 CONFIRMATION_REQUIRED,
149 UNCONFIRMED,
150 UNEXPECTED,
151 UNSUPPORTED,
152 DESKTOP,
153 UNHEALTHY,
154];
155
156impl AppError {
157 pub fn contract(&self) -> ErrorContract {
158 match self {
159 Self::InvalidInput(_) => INVALID_INPUT,
160 Self::Auth(_) => AUTH,
161 Self::ReadOnly(_) => READ_ONLY,
162 Self::Permission(_) => PERMISSION,
163 Self::NotFound(_) => NOT_FOUND,
164 Self::RateLimit(_) => RATE_LIMIT,
165 Self::Api(_) => API,
166 Self::NonInteractive(_) => NON_INTERACTIVE,
167 Self::ConfirmationRequired(_) => CONFIRMATION_REQUIRED,
168 Self::Unconfirmed(_) | Self::UnconfirmedSend { .. } => UNCONFIRMED,
169 Self::Unsupported(_) => UNSUPPORTED,
170 Self::Desktop(_) => DESKTOP,
171 Self::Unhealthy(_) => UNHEALTHY,
172 Self::Io(_) | Self::Unexpected(_) => UNEXPECTED,
173 }
174 }
175}
176
177impl AppError {
178 pub fn with_hint(self, hint: &str) -> Self {
181 let add = |message: String| format!("{message} {hint}");
182 match self {
183 Self::InvalidInput(m) => Self::InvalidInput(add(m)),
184 Self::Auth(m) => Self::Auth(add(m)),
185 Self::ReadOnly(m) => Self::ReadOnly(add(m)),
186 Self::Permission(m) => Self::Permission(add(m)),
187 Self::NotFound(m) => Self::NotFound(add(m)),
188 Self::Api(m) => Self::Api(add(m)),
189 Self::NonInteractive(m) => Self::NonInteractive(add(m)),
190 Self::ConfirmationRequired(m) => Self::ConfirmationRequired(add(m)),
191 Self::Unconfirmed(m) => Self::Unconfirmed(add(m)),
192 Self::UnconfirmedSend {
193 message,
194 chat_id,
195 client_message_id,
196 } => Self::UnconfirmedSend {
197 message: add(message),
198 chat_id,
199 client_message_id,
200 },
201 Self::Unsupported(m) => Self::Unsupported(add(m)),
202 Self::Desktop(m) => Self::Desktop(add(m)),
203 Self::Unhealthy(m) => Self::Unhealthy(add(m)),
204 Self::Unexpected(m) => Self::Unexpected(add(m)),
205 Self::Io(err) => Self::Unexpected(add(err.to_string())),
206 Self::RateLimit(_) => self,
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn with_hint_appends_to_the_message_and_keeps_the_kind() {
218 let err = AppError::Auth("signed out".into()).with_hint("Port still open.");
219 assert_eq!(err.contract().kind, "auth");
220 assert_eq!(err.to_string(), "signed out Port still open.");
221
222 let err = AppError::Desktop("no chats".into()).with_hint("Port still open.");
223 assert_eq!(err.contract().kind, "desktop_error");
224 assert_eq!(err.to_string(), "no chats Port still open.");
225 }
226
227 #[test]
228 fn with_hint_keeps_io_errors_unexpected() {
229 let io = std::io::Error::other("disk full");
230 let err = AppError::Io(io).with_hint("Port still open.");
231 assert_eq!(err.contract().kind, "unexpected_error");
232 assert_eq!(err.to_string(), "disk full Port still open.");
233 }
234
235 #[test]
236 fn with_hint_leaves_rate_limits_alone() {
237 let err = AppError::RateLimit(Some(5)).with_hint("Port still open.");
238 assert!(matches!(err, AppError::RateLimit(Some(5))));
239 }
240}