Skip to main content

teams_cli/
error.rs

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    /// Appends `hint` to the message while keeping the error kind, so callers can say
179    /// what state a failure left behind.
180    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            // The rate-limit message is fixed and carries only the retry delay.
207            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}