1use std::fmt;
2
3#[derive(Debug)]
4pub enum RoamError {
5 Api { status: u16, message: String },
6 Http(reqwest::Error),
7 Config(String),
8 Io(std::io::Error),
9 Json(serde_json::Error),
10 TomlDe(toml::de::Error),
11}
12
13impl fmt::Display for RoamError {
14 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 match self {
16 Self::Api { status, message } => write!(f, "API error ({}): {}", status, message),
17 Self::Http(e) => write!(f, "HTTP error: {}", e),
18 Self::Config(msg) => write!(f, "Config error: {}", msg),
19 Self::Io(e) => write!(f, "IO error: {}", e),
20 Self::Json(e) => write!(f, "JSON error: {}", e),
21 Self::TomlDe(e) => write!(f, "TOML parse error: {}", e),
22 }
23 }
24}
25
26impl std::error::Error for RoamError {
27 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
28 match self {
29 Self::Http(e) => Some(e),
30 Self::Io(e) => Some(e),
31 Self::Json(e) => Some(e),
32 Self::TomlDe(e) => Some(e),
33 _ => None,
34 }
35 }
36}
37
38impl From<reqwest::Error> for RoamError {
39 fn from(e: reqwest::Error) -> Self {
40 Self::Http(e)
41 }
42}
43
44impl From<std::io::Error> for RoamError {
45 fn from(e: std::io::Error) -> Self {
46 Self::Io(e)
47 }
48}
49
50impl From<serde_json::Error> for RoamError {
51 fn from(e: serde_json::Error) -> Self {
52 Self::Json(e)
53 }
54}
55
56impl From<toml::de::Error> for RoamError {
57 fn from(e: toml::de::Error) -> Self {
58 Self::TomlDe(e)
59 }
60}
61
62pub type Result<T> = std::result::Result<T, RoamError>;
63
64#[derive(Debug, Clone, PartialEq)]
66pub enum ErrorInfo {
67 Api { status: u16, body: String },
68 Network(String),
69 Write(String),
70}
71
72impl ErrorInfo {
73 pub fn from_roam_error(e: &RoamError) -> Self {
74 match e {
75 RoamError::Api { status, message } => ErrorInfo::Api {
76 status: *status,
77 body: message.clone(),
78 },
79 _ => ErrorInfo::Network(e.to_string()),
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq)]
86pub struct ErrorPopup {
87 pub title: String,
88 pub message: String,
89 pub hint: String,
90}
91
92impl ErrorPopup {
93 pub fn from_error_info(info: &ErrorInfo) -> Self {
94 match info {
95 ErrorInfo::Api { status, body } => Self::from_api(*status, body),
96 ErrorInfo::Network(msg) => Self {
97 title: "Network Error".into(),
98 message: truncate(msg, 80),
99 hint: "Check your internet connection".into(),
100 },
101 ErrorInfo::Write(msg) => Self {
102 title: "Write Failed".into(),
103 message: truncate(msg, 80),
104 hint: "Your changes may not have been saved".into(),
105 },
106 }
107 }
108
109 fn from_api(status: u16, body: &str) -> Self {
110 let extracted_message = extract_json_message(body);
111
112 match status {
113 429 => Self {
114 title: "Rate Limited".into(),
115 message: extracted_message.unwrap_or_else(|| "Too many requests".into()),
116 hint: "Wait a moment and try again".into(),
117 },
118 401 => Self {
119 title: "Unauthorized".into(),
120 message: "Invalid API token".into(),
121 hint: "Check your config.toml".into(),
122 },
123 403 => Self {
124 title: "Forbidden".into(),
125 message: "Access denied to this graph".into(),
126 hint: "Check graph permissions".into(),
127 },
128 500 => Self {
129 title: "Server Error".into(),
130 message: "Roam servers returned an error".into(),
131 hint: "Try again later".into(),
132 },
133 _ => Self {
134 title: format!("API Error ({})", status),
135 message: extracted_message.unwrap_or_else(|| truncate(body, 200)),
136 hint: "Try again later".into(),
137 },
138 }
139 }
140}
141
142fn extract_json_message(body: &str) -> Option<String> {
143 serde_json::from_str::<serde_json::Value>(body)
144 .ok()
145 .and_then(|v| v.get("message")?.as_str().map(String::from))
146}
147
148fn truncate(s: &str, max: usize) -> String {
149 if s.chars().count() <= max {
150 s.to_string()
151 } else {
152 let truncated: String = s.chars().take(max).collect();
153 format!("{}...", truncated)
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn api_error_displays_status_and_message() {
163 let err = RoamError::Api {
164 status: 401,
165 message: "Unauthorized".into(),
166 };
167 assert_eq!(err.to_string(), "API error (401): Unauthorized");
168 }
169
170 #[test]
171 fn config_error_displays_message() {
172 let err = RoamError::Config("missing api_token".into());
173 assert_eq!(err.to_string(), "Config error: missing api_token");
174 }
175
176 #[test]
177 fn io_error_converts_from_std() {
178 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
179 let err: RoamError = io_err.into();
180 assert!(matches!(err, RoamError::Io(_)));
181 assert!(err.to_string().contains("file not found"));
182 }
183
184 #[test]
185 fn json_error_converts_from_serde() {
186 let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
187 let err: RoamError = json_err.into();
188 assert!(matches!(err, RoamError::Json(_)));
189 }
190
191 #[test]
192 fn toml_error_converts_from_toml_de() {
193 let toml_err = toml::from_str::<toml::Value>("= invalid").unwrap_err();
194 let err: RoamError = toml_err.into();
195 assert!(matches!(err, RoamError::TomlDe(_)));
196 }
197
198 #[test]
199 fn error_popup_from_429_extracts_message() {
200 let info = ErrorInfo::Api {
201 status: 429,
202 body: r#"{"graph-name":"avelino-graph","message":"You've crossed your quotas of 50 req/min/graph, please try again later."}"#.into(),
203 };
204 let popup = ErrorPopup::from_error_info(&info);
205 assert_eq!(popup.title, "Rate Limited");
206 assert!(popup.message.contains("crossed your quotas"));
207 assert_eq!(popup.hint, "Wait a moment and try again");
208 }
209
210 #[test]
211 fn error_popup_from_429_fallback() {
212 let info = ErrorInfo::Api {
213 status: 429,
214 body: "rate limited plain text".into(),
215 };
216 let popup = ErrorPopup::from_error_info(&info);
217 assert_eq!(popup.title, "Rate Limited");
218 assert_eq!(popup.message, "Too many requests");
219 }
220
221 #[test]
222 fn error_popup_from_401() {
223 let info = ErrorInfo::Api {
224 status: 401,
225 body: "".into(),
226 };
227 let popup = ErrorPopup::from_error_info(&info);
228 assert_eq!(popup.title, "Unauthorized");
229 assert_eq!(popup.message, "Invalid API token");
230 assert_eq!(popup.hint, "Check your config.toml");
231 }
232
233 #[test]
234 fn error_popup_from_403() {
235 let info = ErrorInfo::Api {
236 status: 403,
237 body: "".into(),
238 };
239 let popup = ErrorPopup::from_error_info(&info);
240 assert_eq!(popup.title, "Forbidden");
241 }
242
243 #[test]
244 fn error_popup_from_500() {
245 let info = ErrorInfo::Api {
246 status: 500,
247 body: "".into(),
248 };
249 let popup = ErrorPopup::from_error_info(&info);
250 assert_eq!(popup.title, "Server Error");
251 }
252
253 #[test]
254 fn error_popup_from_unknown_status_with_json() {
255 let info = ErrorInfo::Api {
256 status: 502,
257 body: r#"{"message":"bad gateway"}"#.into(),
258 };
259 let popup = ErrorPopup::from_error_info(&info);
260 assert_eq!(popup.title, "API Error (502)");
261 assert_eq!(popup.message, "bad gateway");
262 }
263
264 #[test]
265 fn error_popup_from_unknown_status_plain_text() {
266 let info = ErrorInfo::Api {
267 status: 502,
268 body: "some plain error".into(),
269 };
270 let popup = ErrorPopup::from_error_info(&info);
271 assert_eq!(popup.title, "API Error (502)");
272 assert_eq!(popup.message, "some plain error");
273 }
274
275 #[test]
276 fn error_popup_from_network() {
277 let info = ErrorInfo::Network("connection refused".into());
278 let popup = ErrorPopup::from_error_info(&info);
279 assert_eq!(popup.title, "Network Error");
280 assert_eq!(popup.message, "connection refused");
281 assert_eq!(popup.hint, "Check your internet connection");
282 }
283
284 #[test]
285 fn error_popup_from_write() {
286 let info = ErrorInfo::Write("timeout writing block".into());
287 let popup = ErrorPopup::from_error_info(&info);
288 assert_eq!(popup.title, "Write Failed");
289 assert_eq!(popup.message, "timeout writing block");
290 assert_eq!(popup.hint, "Your changes may not have been saved");
291 }
292
293 #[test]
294 fn error_popup_truncates_long_message() {
295 let long_msg = "a".repeat(100);
296 let info = ErrorInfo::Network(long_msg);
297 let popup = ErrorPopup::from_error_info(&info);
298 assert!(popup.message.len() <= 83); assert!(popup.message.ends_with("..."));
300 }
301
302 #[test]
303 fn error_info_from_roam_api_error() {
304 let err = RoamError::Api {
305 status: 429,
306 message: "rate limited".into(),
307 };
308 let info = ErrorInfo::from_roam_error(&err);
309 match info {
310 ErrorInfo::Api { status, body } => {
311 assert_eq!(status, 429);
312 assert_eq!(body, "rate limited");
313 }
314 _ => panic!("Expected ErrorInfo::Api"),
315 }
316 }
317
318 #[test]
319 fn error_info_from_roam_http_error_becomes_network() {
320 let err = RoamError::Config("bad config".into());
321 let info = ErrorInfo::from_roam_error(&err);
322 assert!(matches!(info, ErrorInfo::Network(_)));
323 }
324}